From b5b092d0d61ca007b52d520bff24ea904dcbb1ad Mon Sep 17 00:00:00 2001 From: jakeriksen Date: Thu, 5 Dec 2024 15:19:29 +0100 Subject: [PATCH 1/5] feat: implement columns --- .../common/ColumnList/Column/index.tsx | 34 +++++++++++++++++++ src/components/common/ColumnList/index.tsx | 33 ++++++++++++++++++ src/constants/BlockComponentsMapper/index.ts | 6 +++- src/constants/BlockComponentsMapper/types.ts | 2 ++ src/styles/components.css | 34 ++++++++++++++++++- src/types/Block.ts | 19 ++++++++++- src/types/BlockTypes.ts | 2 ++ src/types/NotionBlock.ts | 2 ++ 8 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/components/common/ColumnList/Column/index.tsx create mode 100644 src/components/common/ColumnList/index.tsx diff --git a/src/components/common/ColumnList/Column/index.tsx b/src/components/common/ColumnList/Column/index.tsx new file mode 100644 index 0000000..ecc311e --- /dev/null +++ b/src/components/common/ColumnList/Column/index.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react' +import withContentValidation, { DropedProps } from '../../../../hoc/withContentValidation' +import { ParsedBlock } from '../../../../types/Block' + +function Column({ className, config, blockComponentsMapper }: DropedProps) { + const { items } = config.block + + if (!items) return null + + const renderItem = useCallback((block: ParsedBlock) => { + if (!block?.id) return null; + + const Component = block.getComponent(blockComponentsMapper) + if (!Component) return null; + + return ( + + ) + }, [config, blockComponentsMapper, className]) + + return ( +
+ {items.map(renderItem)} +
+ ) +} + +export default withContentValidation(Column) + diff --git a/src/components/common/ColumnList/index.tsx b/src/components/common/ColumnList/index.tsx new file mode 100644 index 0000000..8f8d750 --- /dev/null +++ b/src/components/common/ColumnList/index.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react' +import withContentValidation, { DropedProps } from '../../../hoc/withContentValidation' +import Column from './Column' +import { ParsedBlock } from '../../../types/Block' + +function ColumnList({ className, config, blockComponentsMapper }: DropedProps) { + const { items } = config.block + + if (!items?.length) return null + + const renderColumn = useCallback((item: ParsedBlock) => { + if (!item?.id) return null; + + return ( + + ) + }, [config, blockComponentsMapper, className]) + + return ( +
+ {items.map(renderColumn)} +
+ ) +} + +export default withContentValidation(ColumnList) + + diff --git a/src/constants/BlockComponentsMapper/index.ts b/src/constants/BlockComponentsMapper/index.ts index cc42128..99868d8 100644 --- a/src/constants/BlockComponentsMapper/index.ts +++ b/src/constants/BlockComponentsMapper/index.ts @@ -14,6 +14,8 @@ import Image from '../../components/common/Image/wrappedImage' import Video from '../../components/common/Video/wrappedVideo' import Embed from '../../components/common/Embed/wrappedEmbed' import TableOfContents from '../../components/common/TableOfContents' +import ColumnList from '../../components/common/ColumnList' +import Column from '../../components/common/ColumnList/Column' import { BlockComponentsMapperType } from './types' @@ -40,5 +42,7 @@ export const BlockComponentsMapper: BlockComponentsMapperType = { [blockEnum.TABLE]: Table, [blockEnum.TABLE_ROW]: undefined, [blockEnum.SYNCED_BLOCK]: undefined, - [blockEnum.BOOKMARK]: undefined + [blockEnum.BOOKMARK]: undefined, + [blockEnum.COLUMN_LIST]: ColumnList, + [blockEnum.COLUMN]: Column } diff --git a/src/constants/BlockComponentsMapper/types.ts b/src/constants/BlockComponentsMapper/types.ts index 1c39aec..a9ed667 100644 --- a/src/constants/BlockComponentsMapper/types.ts +++ b/src/constants/BlockComponentsMapper/types.ts @@ -27,4 +27,6 @@ export type BlockComponentsMapperType = { [blockEnum.TABLE_ROW]?: BlockComponent [blockEnum.SYNCED_BLOCK]?: BlockComponent [blockEnum.BOOKMARK]?: BlockComponent + [blockEnum.COLUMN_LIST]?: BlockComponent + [blockEnum.COLUMN]?: BlockComponent } diff --git a/src/styles/components.css b/src/styles/components.css index 95da74b..c3c17bd 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -52,4 +52,36 @@ .rnr-container .has-row-header tr > td:first-child { background-color: var(--light-color); -} \ No newline at end of file +} + +/* Column List */ +.rnr-container .rnr-column_list { + display: grid; + gap: 2rem; + width: 100%; +} + +.rnr-container .rnr-column_list[data-columns="2"] { + grid-template-columns: repeat(2, 1fr); +} + +.rnr-container .rnr-column_list[data-columns="3"] { + grid-template-columns: repeat(3, 1fr); +} + +.rnr-container .rnr-column_list[data-columns="4"] { + grid-template-columns: repeat(4, 1fr); +} + +.rnr-container .rnr-column_list[data-columns="5"] { + grid-template-columns: repeat(5, 1fr); +} + +.rnr-container .rnr-column { + width: 100%; +} + +.rnr-container .rnr-column > *:not(:last-child) { + margin-bottom: 1rem; +} + diff --git a/src/types/Block.ts b/src/types/Block.ts index 8ee8f0f..0a83ddd 100644 --- a/src/types/Block.ts +++ b/src/types/Block.ts @@ -33,7 +33,7 @@ export class ParsedBlock { constructor(initialValues: NotionBlock, isChild?: boolean) { const notionType = initialValues.type as blockEnum - const content = initialValues[notionType] + const content = (initialValues as unknown as Record)[notionType] // TODO: Fix this type casting if (!notionType || !content) return @@ -46,6 +46,11 @@ export class ParsedBlock { } else if (this.isList() && !isChild) { this.content = null this.items = [new ParsedBlock(initialValues, true)] + } else if (this.isColumnList()) { + this.content = null + this.items = content.children?.map((columnBlock: NotionBlock) => { + return new ParsedBlock(columnBlock, true) + }) ?? null } else { const { rich_text, @@ -125,6 +130,10 @@ export class ParsedBlock { case blockEnum.TABLE: case blockEnum.TABLE_OF_CONTENTS: return 'TABLE' + case blockEnum.COLUMN_LIST: + return 'COLUMN_LIST' + case blockEnum.COLUMN: + return 'COLUMN' case blockEnum.CODE: return 'CODE' default: @@ -168,6 +177,14 @@ export class ParsedBlock { return this.getType() === 'TABLE' } + isColumnList() { + return this.getType() === 'COLUMN_LIST' + } + + isColumn() { + return this.getType() === "COLUMN" + } + equalsType(type: blockEnum) { return this.notionType === type } diff --git a/src/types/BlockTypes.ts b/src/types/BlockTypes.ts index bd5845b..fb09f87 100644 --- a/src/types/BlockTypes.ts +++ b/src/types/BlockTypes.ts @@ -22,6 +22,8 @@ export enum blockEnum { TABLE_OF_CONTENTS = 'table_of_contents', TABLE = 'table', TABLE_ROW = 'table_row', + COLUMN_LIST = 'column_list', + COLUMN = 'column' } export const UNSUPPORTED_TYPE = 'unsupported' diff --git a/src/types/NotionBlock.ts b/src/types/NotionBlock.ts index a61be13..88a7f23 100644 --- a/src/types/NotionBlock.ts +++ b/src/types/NotionBlock.ts @@ -37,6 +37,8 @@ interface Block { [blockEnum.TABLE_ROW]?: BlockTypeContent & { cells: Text[]; } + [blockEnum.COLUMN]?: BlockTypeContent + [blockEnum.COLUMN_LIST]?: BlockTypeContent } export type NotionBlock = Block | Title From 8fdb156bf3af5311aa0a9d04e89219cc231646c8 Mon Sep 17 00:00:00 2001 From: jakeriksen Date: Thu, 5 Dec 2024 15:23:01 +0100 Subject: [PATCH 2/5] feat: add example for columns --- dev-example/data/columnListBlocks.json | 168 +++++++++++++++++++++++++ dev-example/lib/notion.js | 35 ++++++ dev-example/pages/[id].js | 45 +++---- dev-example/pages/index.js | 7 +- 4 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 dev-example/data/columnListBlocks.json diff --git a/dev-example/data/columnListBlocks.json b/dev-example/data/columnListBlocks.json new file mode 100644 index 0000000..96eedc7 --- /dev/null +++ b/dev-example/data/columnListBlocks.json @@ -0,0 +1,168 @@ +{ + "object": "list", + "results": [{ + "object": "block", + "id": "dummy-id-1", + "parent": { + "type": "page_id", + "page_id": "dummy-page-id-1" + }, + "created_time": "2024-12-05T10:34:00.000Z", + "last_edited_time": "2024-12-05T10:34:00.000Z", + "created_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "last_edited_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "has_children": true, + "archived": false, + "in_trash": false, + "type": "column_list", + "column_list": { + "children": [ + { + "object": "block", + "id": "dummy-id-2", + "parent": { + "type": "block_id", + "block_id": "dummy-id-1" + }, + "created_time": "2024-12-05T10:34:00.000Z", + "last_edited_time": "2024-12-05T10:34:00.000Z", + "created_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "last_edited_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "has_children": true, + "archived": false, + "in_trash": false, + "type": "column", + "column": { + "children": [ + { + "object": "block", + "id": "dummy-id-3", + "parent": { + "type": "block_id", + "block_id": "dummy-id-2" + }, + "created_time": "2024-12-05T10:34:00.000Z", + "last_edited_time": "2024-12-05T13:23:00.000Z", + "created_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "last_edited_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "paragraph", + "paragraph": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "LeftCol", + "link": null + }, + "annotations": { + "bold": false, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default" + }, + "plain_text": "LeftCol", + "href": null + } + ], + "color": "default" + } + } + ] + } + }, + { + "object": "block", + "id": "dummy-id-4", + "parent": { + "type": "block_id", + "block_id": "dummy-id-1" + }, + "created_time": "2024-12-05T10:34:00.000Z", + "last_edited_time": "2024-12-05T10:34:00.000Z", + "created_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "last_edited_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "has_children": true, + "archived": false, + "in_trash": false, + "type": "column", + "column": { + "children": [ + { + "object": "block", + "id": "dummy-id-5", + "parent": { + "type": "block_id", + "block_id": "dummy-id-4" + }, + "created_time": "2024-12-05T10:34:00.000Z", + "last_edited_time": "2024-12-05T13:23:00.000Z", + "created_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "last_edited_by": { + "object": "user", + "id": "dummy-user-id-1" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "paragraph", + "paragraph": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "RightCol", + "link": null + }, + "annotations": { + "bold": false, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default" + }, + "plain_text": "RightCol", + "href": null + } + ], + "color": "default" + } + } + ] + } + } + ] + } + }]} \ No newline at end of file diff --git a/dev-example/lib/notion.js b/dev-example/lib/notion.js index 7219a81..be94cb1 100644 --- a/dev-example/lib/notion.js +++ b/dev-example/lib/notion.js @@ -22,3 +22,38 @@ export const getBlocks = async (blockId) => { }) return response.results } + + + +export const getBlocksWithChildren = async (pageId) => { + const response = await notion.blocks.children.list({ + block_id: pageId, + }) + const blocks = response.results; + + const childBlocks = await Promise.all( + blocks + .filter((block) => block.has_children) + .map(async (block) => { + return { + id: block.id, + children: await getBlocksWithChildren(block.id) + } + }) + ); + + const blocksWithChildren = blocks.map((block) => { + // Add child blocks if the block should contain children but none exists + if (block.has_children) { + const blockType = block.type; + if (blockType in block && !block[blockType].children) { + block[blockType].children = childBlocks.find( + (x) => x.id === block.id + )?.children; + } + } + return block + }); + + return blocksWithChildren; +} diff --git a/dev-example/pages/[id].js b/dev-example/pages/[id].js index 9cd1ae9..131724d 100644 --- a/dev-example/pages/[id].js +++ b/dev-example/pages/[id].js @@ -1,7 +1,7 @@ import React from 'react' import Head from 'next/head' import NextImg from 'next/image' -import { getDatabase, getPage, getBlocks } from '../lib/notion' +import { getDatabase, getPage, getBlocks, getBlocksWithChildren } from '../lib/notion' import Link from 'next/link' import { databaseId } from './blog.js' @@ -28,6 +28,7 @@ export default function Post({ page, blocks }) { return
} + return ( <> @@ -69,29 +70,31 @@ export const getStaticPaths = async () => { export const getStaticProps = async (context) => { const { id } = context.params const page = await getPage(id) - const blocks = await getBlocks(id) + // const blocks = await getBlocks(id) // Retrieve block children for nested blocks (one level deep), for example toggle blocks // https://developers.notion.com/docs/working-with-page-content#reading-nested-blocks - const childBlocks = await Promise.all( - blocks - .filter((block) => block.has_children) - .map(async (block) => { - return { - id: block.id, - children: await getBlocks(block.id) - } - }) - ) - const blocksWithChildren = blocks.map((block) => { - // Add child blocks if the block should contain children but none exists - if (block.has_children && !block[block.type].children) { - block[block.type].children = childBlocks.find( - (x) => x.id === block.id - )?.children - } - return block - }) + // const childBlocks = await Promise.all( + // blocks + // .filter((block) => block.has_children) + // .map(async (block) => { + // return { + // id: block.id, + // children: await getBlocks(block.id) + // } + // }) + // ) + // const blocksWithChildren = blocks.map((block) => { + // // Add child blocks if the block should contain children but none exists + // if (block.has_children && !block[block.type].children) { + // block[block.type].children = childBlocks.find( + // (x) => x.id === block.id + // )?.children + // } + // return block + // }) + + const blocksWithChildren = await getBlocksWithChildren(id) return { props: { diff --git a/dev-example/pages/index.js b/dev-example/pages/index.js index 89bf372..37b7f84 100644 --- a/dev-example/pages/index.js +++ b/dev-example/pages/index.js @@ -1,9 +1,11 @@ import React from 'react' import Link from 'next/link' import { Render } from '@9gustin/react-notion-render' +// import "../../dist/index.css" // <-- this is the css file from the package import notionResponse from '../data/mockVideos.json' import title from '../data/title.json' +import columnListResponse from '../data/columnListBlocks.json' export default function mockedPage() { return ( @@ -12,9 +14,10 @@ export default function mockedPage() { This page is mockuped with data/blocks.json, also you can view{' '} /blog - +
- + +
) From 5e371acb34c2cee480c5d5cfa03e49d815a88fb1 Mon Sep 17 00:00:00 2001 From: jakeriksen Date: Thu, 5 Dec 2024 15:23:25 +0100 Subject: [PATCH 3/5] chore: react/types --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index bab9e88..0237895 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.2.6", + "@types/react": "^18.3.13", "@typescript-eslint/parser": "^5.31.0", "eslint": "^8.20.0", "jest": "^28.1.3", From ad81f2b1ad6f7cb6bd7df759b4731e926c0bfa88 Mon Sep 17 00:00:00 2001 From: jakeriksen Date: Thu, 5 Dec 2024 15:23:42 +0100 Subject: [PATCH 4/5] feat: update readme for columns with example --- README.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9fcb537..4373556 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,37 @@ ![Stars](https://img.shields.io/github/stars/9gustin/react-notion-render.svg?style=social) ## Table of contents - - [Description](#description) - - [Installation](#installation) - - [Examples](#examples) - - [Basic example](#basic-example) - - [Blog with Notion as CMS](#blog-with-notion-as-cms) - - [Notion page to single page](#notion-page-to-single-page) - - [Usage](#usage) - - [Override built-in components (new)](#override-built-in-components-new) - - [Giving Styles](#giving-styles) - - [...moreProps](#moreprops) - - [Custom Components](#custom-components) - - [Display a custom table of contents](#display-a-custom-table-of-contents) - - [Guides](#guides) - - [How to use code blocks](https://github.com/9gustin/react-notion-render/wiki/About-code-blocks-and-how-to-colorize-it-%F0%9F%8E%A8) - - [Supported blocks](#supported-blocks) - - [Contributions](#contributions) +- [Table of contents](#table-of-contents) +- [Description](#description) +- [Installation](#installation) +- [Examples](#examples) + - [Basic example](#basic-example) + - [Fetching data with @notionhq/client](#fetching-data-with-notionhqclient) + - [Blog with Notion as CMS](#blog-with-notion-as-cms) + - [Notion page to single page](#notion-page-to-single-page) +- [Usage](#usage) + - [Override built-in components (new)](#override-built-in-components-new) + - [How works?](#how-works) + - [Mapping page url](#mapping-page-url) + - [Giving styles](#giving-styles) + - [Using default styles](#using-default-styles) + - [Using your own styles](#using-your-own-styles) + - [...moreProps](#moreprops) + - [Custom title url](#custom-title-url) + - [Preserve empty blocks](#preserve-empty-blocks) + - [Custom components](#custom-components) + - [Link](#link) + - [Image](#image) + - [Video](#video) + - [Display a custom table of contents](#display-a-custom-table-of-contents) +- [Guides](#guides) + - [How to use code blocks](#how-to-use-code-blocks) +- [Supported blocks](#supported-blocks) +- [Contributions:](#contributions) + - [Running the dev example](#running-the-dev-example) + - [Running another example](#running-another-example) + - [Project structure](#project-structure) +- [License](#license) ## Description @@ -62,6 +77,89 @@ export const getStaticProps = async () => { } ``` +### Fetching data with @notionhq/client + +```jsx +// e.g. /lib/notion-cms.ts + +import { Client } from '@notionhq/client' + +// Initialize a new client +const DATABASE_ID = '54d0ff3097694ad08bd21932d598b93d' +const notion = new Client({ auth: process.env.NOTION_TOKEN }) + +export const fetchPages = cache(() => { + return notion.databases.query({ + database_id: DATABASE_ID, + filter: { + property: "status", + status: { + equals: "live", + } + } // if you have a status property for controlling, you can easily filter it + + }) +}) + +// If you have a slug property you can use this function to get the page by slug +export const fetchBySlug = cache((slug: string) => { + return notion.databases.query({ + database_id: notionBlogDatabaseId, + filter: { + property: "slug", + rich_text: { + equals: slug, + }, + } + }) + .then((res) => res.results[0] as PageObjectResponse | undefined) +}) + +// OR - you can simply just use the page id +export const getPageById = cache((pageId: string) => { + return notion.pages.retrieve({ + page_id: pageId, + }) +}) + +// This is a function to get the blocks of a page (and the children blocks) +export const getBlocksWithChildren = cache(async (pageId: string) => { + const response = await notion.blocks.children.list({ + block_id: pageId, + }) + const blocks = response.results as BlockObjectResponse[]; + + const childBlocks = await Promise.all( + blocks + .filter((block) => block.has_children) + .map(async (block) => { + return { + id: block.id, + children: await fetchPageBlock(block.id) + } + }) + ); + + const blocksWithChildren = blocks.map((block) => { + // Add child blocks if the block should contain children but none exists + if (block.has_children) { + const blockType = block.type; + if (blockType in block && !(block as any)[blockType].children) { + (block as any)[blockType].children = childBlocks.find( + (x) => x.id === block.id + )?.children; + } + } + return block + }); + + return blocksWithChildren; +}) +``` + +After initializing this, you can use the functions in the example above to get the blocks of a page. See the basic example above. + + ### Blog with Notion as CMS I've maded a template to blog page, that use this package and allows you have a blog using notion as CMS.
@@ -344,6 +442,7 @@ Most common block types are supported. We happily accept pull requests to add su | Table Of Contents | ✅ | | Table | ✅ | | Synced blocks | ✅ | +| Columns | ✅ | | Web Bookmark | ❌ | ## Contributions: From 22f984dc7e0df2671781a6672ef69aa779e83d84 Mon Sep 17 00:00:00 2001 From: jakeriksen Date: Thu, 5 Dec 2024 15:27:33 +0100 Subject: [PATCH 5/5] chore: update readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4373556..8dc2b92 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ export const fetchPages = cache(() => { status: { equals: "live", } - } // if you have a status property for controlling, you can easily filter it + } // if you have a status property for controlling the post status, you can easily filter it }) }) @@ -275,6 +275,8 @@ This is independient to the prop **useStyles**, you can combinate them or use se | rnr-table_of_contents | Table of contents | ul | | rnr-table | Table | table | | rnr-table_row | Table row | tr | +| rnr-column_list | ColumnList | div | +| rnr-column | Column | div | **Text Styles**