From 29c373e4656f3ad8263d7ef0ee6d6579727f67fd Mon Sep 17 00:00:00 2001 From: cohitre Date: Thu, 22 Feb 2024 12:41:29 -0800 Subject: [PATCH 1/3] Populating docs --- README.md | 52 +++++++++- src/builders/buildBlockComponent.ts | 2 +- src/builders/buildDocumentEditor.ts | 68 ------------ src/builders/buildDocumentReader.ts | 46 ++++----- src/index.tsx | 1 - tests/builder/buildDocumentEditor.spec.tsx | 114 --------------------- tests/builder/buildDocumentReader.spec.tsx | 16 +-- 7 files changed, 79 insertions(+), 220 deletions(-) delete mode 100644 src/builders/buildDocumentEditor.ts delete mode 100644 tests/builder/buildDocumentEditor.spec.tsx diff --git a/README.md b/README.md index 22932f7..a0ae7b0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,53 @@ # @usewaypoint/document -A library to render waypoint-style documents +This is the core library used to build the email messages at [Waypoint](https://www.usewaypoint.com). It is non-opinionated and light on dependencies so that it can be used to compose complex documents. + +> [!WARNING] +> This library is still under development and the final interface is subject +> to change + +## Installation + +**Installation with npm** + +``` +npm install --save @usewaypoint/document +``` + +## Usage + +The root of the library is the `DocumentBlocksDictionary` dictionary. This is a mapping of block names to an object with a zod schema and a corresponding React Component. + +``` +const dictionary = { + Alert: { + schema: z.object({ + message: z.string(), + }), + Component: ({ message }: { message: string }) => { + return
{message.toUpperCase()}
+ } + } +} +``` + +This dictionary object is passed as an argument to the builder functions. + +### `buildBlockComponent` + +``` +const Block = buildBlockComponent(dictionary); + + +``` + +### `buildBlockConfigurationSchema` + +``` +const Schema = buildBlockConfigurationSchema(dictionary); + +const parsedData = Schema.safeParse({ + type: 'Alert', + data: { message: 'Hello World' }, +}); +``` diff --git a/src/builders/buildBlockComponent.ts b/src/builders/buildBlockComponent.ts index 3a5cb14..40879eb 100644 --- a/src/builders/buildBlockComponent.ts +++ b/src/builders/buildBlockComponent.ts @@ -7,7 +7,7 @@ import { BaseZodDictionary, BlockConfiguration, DocumentBlocksDictionary } from * @returns React component that can render a BlockConfiguration that is compatible with blocks */ export default function buildBlockComponent(blocks: DocumentBlocksDictionary) { - return function BlockComponent({ type, data }: BlockConfiguration): React.ReactNode { + return function BlockComponent({ type, data }: BlockConfiguration) { return React.createElement(blocks[type].Component, data); }; } diff --git a/src/builders/buildDocumentEditor.ts b/src/builders/buildDocumentEditor.ts deleted file mode 100644 index 65b4d17..0000000 --- a/src/builders/buildDocumentEditor.ts +++ /dev/null @@ -1,68 +0,0 @@ -import React, { createContext, useContext, useMemo, useState } from 'react'; -import { z } from 'zod'; - -import { BaseZodDictionary, BlockNotFoundError, DocumentBlocksDictionary } from '../utils'; - -import buildBlockComponent from './buildBlockComponent'; -import buildBlockConfigurationByIdSchema from './buildBlockConfigurationByIdSchema'; - -/** - * @typedef {Object} DocumentEditor - * @property DocumentEditorProvider - Entry point to the DocumentEditor - * @property DocumentConfigurationSchema - zod schema compatible with the value that DocumentReaderProvider expects - * @property Block - Component to render a block given an id - * @property useDocumentState - Hook that returns the current DocumentState and a setter - * @property useBlockState - Hook that returns the Block value and setter given an id - */ - -/** - * @param {DocumentBlocksDictionary} blocks root configuration - * @returns {DocumentEditor} - */ -export default function buildDocumentEditor(blocks: DocumentBlocksDictionary) { - const schema = buildBlockConfigurationByIdSchema(blocks); - const BlockComponent = buildBlockComponent(blocks); - - type TValue = z.infer; - type TDocumentContextState = [value: TValue, setValue: (v: TValue) => void]; - - const Context = createContext([{}, () => {}]); - - type TProviderProps = { - value: z.infer; - children?: Parameters[0]['children']; - }; - - const useDocumentState = () => useContext(Context); - const useBlockState = (id: string | null | undefined) => { - const [value, setValue] = useDocumentState(); - return useMemo(() => { - if (id === null || id === undefined) { - return null; - } - return [ - value[id], - (block: TValue[string]) => { - setValue({ ...value, [id]: block }); - }, - ] as const; - }, [value, setValue, id]); - }; - return { - useDocumentState, - useBlockState, - DocumentConfigurationSchema: schema, - Block: ({ id }: { id: string }) => { - const state = useBlockState(id); - if (state === null || !state[0]) { - throw new BlockNotFoundError(id); - } - const { type, data } = state[0]; - return React.createElement(BlockComponent, { type, data }); - }, - DocumentEditorProvider: ({ value, children }: TProviderProps) => { - const state = useState(value); - return React.createElement(Context.Provider, { value: state, children }); - }, - }; -} diff --git a/src/builders/buildDocumentReader.ts b/src/builders/buildDocumentReader.ts index 7992d20..b37cb8b 100644 --- a/src/builders/buildDocumentReader.ts +++ b/src/builders/buildDocumentReader.ts @@ -1,18 +1,18 @@ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useContext } from 'react'; import { z } from 'zod'; import { BaseZodDictionary, BlockNotFoundError, DocumentBlocksDictionary } from '../utils'; import buildBlockComponent from './buildBlockComponent'; -import buildBlockConfigurationByIdSchema from './buildBlockConfigurationByIdSchema'; +import buildBlockConfigurationSchema from './buildBlockConfigurationSchema'; /** * @typedef {Object} DocumentReader - * @property DocumentReaderProvider - Entry point to the DocumentReader - * @property DocumentConfigurationSchema - zod schema compatible with the value that DocumentReaderProvider expects - * @property Block - Component to render a block given an id + * @property DocumentProvider - Entry point to the DocumentReader + * @property BlockSchema - zod schema for a Document block + * @property DocumentSchema - zod schema compatible with the value that DocumentReaderProvider expects + * @property Block - React Component to render a block by type/data as defined by the DocumentSchema * @property useDocument - Hook that returns the current Document - * @property useBlock - Hook that returns the block given an id */ /** @@ -20,37 +20,29 @@ import buildBlockConfigurationByIdSchema from './buildBlockConfigurationByIdSche * @returns {DocumentReader} */ export default function buildDocumentReader(blocks: DocumentBlocksDictionary) { - const schema = buildBlockConfigurationByIdSchema(blocks); - const BlockComponent = buildBlockComponent(blocks); + const RawBlock = buildBlockComponent(blocks); - type TValue = z.infer; - type TDocumentContextState = { value: TValue }; + const BlockSchema = buildBlockConfigurationSchema(blocks); + const DocumentSchema = z.record(z.string(), BlockSchema); - const Context = createContext({ value: {} }); + type TBlock = z.infer; + type TDocument = Record; - type TProviderProps = { - value: z.infer; - children?: Parameters[0]['children']; - }; - - const useDocument = () => useContext(Context).value; - const useBlock = (id: string) => useDocument()[id]; + const Context = createContext({}); + const useDocument = () => useContext(Context); return { + BlockSchema, + DocumentSchema, + RawBlock, useDocument, - useBlock, - DocumentConfigurationSchema: schema, + DocumentProvider: Context.Provider, Block: ({ id }: { id: string }) => { - const block = useBlock(id); + const block = useDocument()[id]; if (!block) { throw new BlockNotFoundError(id); } - const { type, data } = block; - return React.createElement(BlockComponent, { type, data }); - }, - DocumentReaderProvider: ({ value, children }: TProviderProps) => { - const v = useMemo(() => ({ value }), [value]); - return React.createElement(Context.Provider, { value: v, children }); + return React.createElement(RawBlock, block); }, }; } diff --git a/src/index.tsx b/src/index.tsx index 3f73871..f6f4c3a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,5 +2,4 @@ export { default as buildBlockComponent } from './builders/buildBlockComponent'; export { default as buildBlockConfigurationSchema } from './builders/buildBlockConfigurationSchema'; export { default as buildBlockConfigurationByIdSchema } from './builders/buildBlockConfigurationByIdSchema'; export { default as buildDocumentReader } from './builders/buildDocumentReader'; -export { default as buildDocumentEditor } from './builders/buildDocumentEditor'; export { BlockConfiguration, DocumentBlocksDictionary } from './utils'; diff --git a/tests/builder/buildDocumentEditor.spec.tsx b/tests/builder/buildDocumentEditor.spec.tsx deleted file mode 100644 index 4fd14fb..0000000 --- a/tests/builder/buildDocumentEditor.spec.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react'; -import { z } from 'zod'; - -import { act, render } from '@testing-library/react'; - -import buildDocumentEditor from '../../src/builders/buildDocumentEditor'; - -describe('builders/buildDocumentEditor', () => { - const { useDocumentState, useBlockState, Block, DocumentEditorProvider } = buildDocumentEditor({ - SampleBlock: { - schema: z.object({ text: z.string() }), - Component: ({ text }) =>
{text.toUpperCase()}
, - }, - }); - - const SAMPLE_DATA = { - 'my id': { - id: 'my id', - type: 'SampleBlock' as const, - data: { text: 'Test text!' }, - }, - }; - - describe('#useDocumentState', () => { - it('returns a getter and a setter tuple', () => { - let tuple: any; - const ViewBlockConfig = () => { - tuple = useDocumentState(); - return
{JSON.stringify(tuple[0])}
; - }; - - const NODE = ( - - - - ); - const { rerender } = render(NODE); - expect(tuple[0]).toEqual({ - 'my id': { - id: 'my id', - type: 'SampleBlock', - data: { text: 'Test text!' }, - }, - }); - - act(() => { - tuple[1]({ - 'another id': { - id: 'another id', - type: 'SampleBlock', - data: { text: 'changed text?' }, - }, - }); - }); - - rerender(NODE); - expect(tuple[0]).toEqual({ - 'another id': { - id: 'another id', - type: 'SampleBlock' as const, - data: { text: 'changed text?' }, - }, - }); - }); - }); - - describe('#useBlockState', () => { - it('returns a getter and a setter tuple', () => { - let tuple: any; - const ViewBlockConfig = () => { - tuple = useBlockState('my id'); - return
{JSON.stringify(tuple[0])}
; - }; - - const NODE = ( - - - - ); - const { rerender } = render(NODE); - expect(tuple[0]).toEqual({ - id: 'my id', - type: 'SampleBlock', - data: { text: 'Test text!' }, - }); - - act(() => { - tuple[1]({ - ...tuple[0], - data: { text: 'changed text?' }, - }); - }); - - rerender(NODE); - expect(tuple[0]).toEqual({ - id: 'my id', - type: 'SampleBlock', - data: { text: 'changed text?' }, - }); - }); - }); - - describe('#Block', () => { - it('renders the component from the BlocksConfiguration', () => { - expect( - render( - - - - ).queryAllByText('TEST TEXT!') - ).toHaveLength(1); - }); - }); -}); diff --git a/tests/builder/buildDocumentReader.spec.tsx b/tests/builder/buildDocumentReader.spec.tsx index 35d7dee..d9aa2f9 100644 --- a/tests/builder/buildDocumentReader.spec.tsx +++ b/tests/builder/buildDocumentReader.spec.tsx @@ -6,7 +6,7 @@ import { render } from '@testing-library/react'; import buildDocumentReader from '../../src/builders/buildDocumentReader'; describe('builders/buildDocumentReader', () => { - const { DocumentReaderProvider, Block, useBlock, useDocument } = buildDocumentReader({ + const { DocumentProvider, Block, useDocument } = buildDocumentReader({ SampleBlock: { schema: z.object({ text: z.string() }), Component: ({ text }) =>
{text.toUpperCase()}
, @@ -29,9 +29,9 @@ describe('builders/buildDocumentReader', () => { return
{JSON.stringify(RESULT)}
; }; render( - + - + ); expect(RESULT).toEqual({ 'my id': { @@ -47,13 +47,13 @@ describe('builders/buildDocumentReader', () => { it('gets the value given an id', () => { let RESULT; const ViewBlockConfig = () => { - RESULT = useBlock('my id'); + RESULT = useDocument()['my id']; return
{JSON.stringify(RESULT)}
; }; render( - + - + ); expect(RESULT).toEqual({ id: 'my id', @@ -67,9 +67,9 @@ describe('builders/buildDocumentReader', () => { it('renders the component from the BlocksConfiguration', () => { expect( render( - + - + ).queryAllByText('TEST TEXT!') ).toHaveLength(1); }); From 7fb9c2a9284c353f83dd2a2087acd7d1768404c7 Mon Sep 17 00:00:00 2001 From: cohitre Date: Thu, 22 Feb 2024 12:43:17 -0800 Subject: [PATCH 2/3] Adding tsc to ci --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c3a5342..6da15d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,4 +21,5 @@ jobs: - run: npm ci - run: npx eslint . - run: npx prettier . --check + - run: npx tsc --noEmit - run: npm test From fd4312af10d5166c156db9ef83b3f72a22707476 Mon Sep 17 00:00:00 2001 From: cohitre Date: Thu, 22 Feb 2024 12:46:12 -0800 Subject: [PATCH 3/3] License --- LICENSE | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a09b83 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Carlos Rodriguez-Rosario + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.