diff --git a/package-lock.json b/package-lock.json index a221809..cbeb1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@usewaypoint/document", - "version": "0.0.1", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@usewaypoint/document", - "version": "0.0.1", + "version": "0.0.4", "license": "MIT", "dependencies": { "react": "^18.2.0", diff --git a/package.json b/package.json index 0b19608..0e3f05e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@usewaypoint/document", - "version": "0.0.1", + "version": "0.0.4", "description": "Tools to render waypoint-style documents.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/builders/buildBlockComponent.ts b/src/builders/buildBlockComponent.ts new file mode 100644 index 0000000..d4b6ce9 --- /dev/null +++ b/src/builders/buildBlockComponent.ts @@ -0,0 +1,15 @@ +import React from 'react'; +import { z } from 'zod'; + +import { BaseZodDictionary, DocumentBlocksDictionary } from '../utils'; + +export default function buildBlockComponent(blocks: DocumentBlocksDictionary) { + type BaseBlockComponentProps = { + type: TType; + data: z.infer; + }; + + return function BlockComponent({ type, data }: BaseBlockComponentProps): React.ReactNode { + return React.createElement(blocks[type].Component, data); + }; +} diff --git a/src/builders/buildBlockConfigurationByIdSchema.ts b/src/builders/buildBlockConfigurationByIdSchema.ts new file mode 100644 index 0000000..fd6dfb3 --- /dev/null +++ b/src/builders/buildBlockConfigurationByIdSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { BaseZodDictionary, DocumentBlocksDictionary } from '../utils'; + +import buildBlockConfigurationSchema from './buildBlockConfigurationSchema'; + +export default function buildBlockConfigurationByIdSchema( + blocks: DocumentBlocksDictionary +) { + const schema = buildBlockConfigurationSchema(blocks); + return z.record(z.string(), schema); +} diff --git a/src/builders/buildBlockConfigurationSchema.ts b/src/builders/buildBlockConfigurationSchema.ts new file mode 100644 index 0000000..b5d5229 --- /dev/null +++ b/src/builders/buildBlockConfigurationSchema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { BaseZodDictionary, DocumentBlocksDictionary } from '../utils'; + +export default function buildBlockConfigurationSchema( + blocks: DocumentBlocksDictionary +) { + type BaseBlockComponentProps = { + id: string; + type: TType; + data: z.infer; + }; + + const blockObjects = Object.keys(blocks).map((type: keyof T) => + z.object({ + id: z.string(), + type: z.literal(type), + data: blocks[type].schema, + }) + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return z.discriminatedUnion('type', blockObjects as any).transform((v) => v as BaseBlockComponentProps); +} diff --git a/src/builders/buildDocumentEditor.ts b/src/builders/buildDocumentEditor.ts new file mode 100644 index 0000000..6c991b8 --- /dev/null +++ b/src/builders/buildDocumentEditor.ts @@ -0,0 +1,53 @@ +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'; + +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) => { + const [value, setValue] = useDocumentState(); + return useMemo( + () => + [ + value[id], + (block: TValue[string]) => { + setValue({ ...value, [id]: block }); + }, + ] as const, + [value, setValue, id] + ); + }; + return { + useDocumentState, + useBlockState, + Block: ({ id }: { id: string }) => { + const [block] = useBlockState(id); + if (!block) { + throw new BlockNotFoundError(id); + } + const { type, data } = block; + 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 new file mode 100644 index 0000000..5497528 --- /dev/null +++ b/src/builders/buildDocumentReader.ts @@ -0,0 +1,41 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { z } from 'zod'; + +import { BaseZodDictionary, BlockNotFoundError, DocumentBlocksDictionary } from '../utils'; + +import buildBlockComponent from './buildBlockComponent'; +import buildBlockConfigurationByIdSchema from './buildBlockConfigurationByIdSchema'; + +export default function buildDocumentReader(blocks: DocumentBlocksDictionary) { + const schema = buildBlockConfigurationByIdSchema(blocks); + const BlockComponent = buildBlockComponent(blocks); + + type TValue = z.infer; + type TDocumentContextState = { value: TValue }; + + const Context = createContext({ value: {} }); + + type TProviderProps = { + value: z.infer; + children?: Parameters[0]['children']; + }; + + const useDocument = () => useContext(Context).value; + const useBlock = (id: string) => useDocument()[id]; + + return { + useBlock, + Block: ({ id }: { id: string }) => { + const block = useBlock(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 }); + }, + }; +} diff --git a/src/builders/index.tsx b/src/builders/index.tsx deleted file mode 100644 index 957e9a3..0000000 --- a/src/builders/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { z } from 'zod'; - -export type DocumentBlocksDictionary = { - [K in keyof T]: { - schema: T[K]; - Component: (props: z.infer) => React.ReactNode; - }; -}; - -export function buildBlockConfigurationSchema( - blocks: DocumentBlocksDictionary -) { - type BaseBlockComponentProps = { - id: string; - type: TType; - data: z.infer; - }; - - const blockObjects = Object.keys(blocks).map((type: keyof T) => - z.object({ - id: z.string(), - type: z.literal(type), - data: blocks[type].schema, - }) - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return z.discriminatedUnion('type', blockObjects as any).transform((v) => v as BaseBlockComponentProps); -} - -export function buildBlockComponent(blocks: DocumentBlocksDictionary) { - type BaseBlockComponentProps = { - type: TType; - data: z.infer; - }; - - return function BlockComponent({ type, data }: BaseBlockComponentProps): React.ReactNode { - return React.createElement(blocks[type].Component, data); - }; -} diff --git a/src/index.tsx b/src/index.tsx index a00a45c..8b0ee5b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1 +1,5 @@ -export * from './builders'; +export { default as buildBlockComponent } from './builders/buildBlockComponent'; +export { default as buildBlockConfigurationSchema } from './builders/buildBlockConfigurationSchema'; +export { default as buildBlockConfigurationByIdSchema } from './builders/buildBlockConfigurationByIdSchema'; +export { default as buildDocumentDictionaryContext } from './builders/buildDocumentReader'; +export { DocumentBlocksDictionary } from './utils'; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ad80bc4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,18 @@ +import React from 'react'; +import { z } from 'zod'; + +export type BaseZodDictionary = { [name: string]: z.AnyZodObject }; +export type DocumentBlocksDictionary = { + [K in keyof T]: { + schema: T[K]; + Component: (props: z.infer) => React.ReactNode; + }; +}; + +export class BlockNotFoundError extends Error { + blockId: string; + constructor(blockId: string) { + super('Could not find a block with the given blockId'); + this.blockId = blockId; + } +} diff --git a/tests/builder/__snapshots__/index.spec.tsx.snap b/tests/builder/__snapshots__/buildBlockComponent.spec.tsx.snap similarity index 67% rename from tests/builder/__snapshots__/index.spec.tsx.snap rename to tests/builder/__snapshots__/buildBlockComponent.spec.tsx.snap index 9f714f0..69e0906 100644 --- a/tests/builder/__snapshots__/index.spec.tsx.snap +++ b/tests/builder/__snapshots__/buildBlockComponent.spec.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`builders buildBlockComponent renders the specified component 1`] = ` +exports[`builders/buildBlockComponent renders the specified component 1`] = `
TEST TEXT! diff --git a/tests/builder/buildBlockComponent.spec.tsx b/tests/builder/buildBlockComponent.spec.tsx new file mode 100644 index 0000000..bb25140 --- /dev/null +++ b/tests/builder/buildBlockComponent.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { z } from 'zod'; + +import { render } from '@testing-library/react'; + +import buildBlockComponent from '../../src/builders/buildBlockComponent'; + +describe('builders/buildBlockComponent', () => { + it('renders the specified component', () => { + const BlockComponent = buildBlockComponent({ + SampleBlock: { + schema: z.object({ text: z.string() }), + Component: ({ text }) =>
{text.toUpperCase()}
, + }, + }); + expect(render().asFragment()).toMatchSnapshot(); + }); +}); diff --git a/tests/builder/buildBlockConfigurationByIdSchema.spec.tsx b/tests/builder/buildBlockConfigurationByIdSchema.spec.tsx new file mode 100644 index 0000000..26beb0e --- /dev/null +++ b/tests/builder/buildBlockConfigurationByIdSchema.spec.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { z } from 'zod'; + +import buildBlockConfigurationByIdSchema from '../../src/builders/buildBlockConfigurationByIdSchema'; + +describe('builders/buildBlockConfigurationByIdSchema', () => { + it('parses an object with id as keys and BlockConfiguration as body', () => { + const schema = buildBlockConfigurationByIdSchema({ + SampleBlock: { + schema: z.object({ text: z.string() }), + Component: ({ text }) =>
{text.toUpperCase()}
, + }, + }); + const parsedData = schema.safeParse({ + 'my id': { + id: 'my id', + type: 'SampleBlock', + data: { text: 'Test text!' }, + }, + }); + expect(parsedData).toEqual({ + success: true, + data: { + 'my id': { + id: 'my id', + type: 'SampleBlock', + data: { text: 'Test text!' }, + }, + }, + }); + }); +}); diff --git a/tests/builder/buildBlockConfigurationSchema.spec.tsx b/tests/builder/buildBlockConfigurationSchema.spec.tsx new file mode 100644 index 0000000..d72cf1b --- /dev/null +++ b/tests/builder/buildBlockConfigurationSchema.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { z } from 'zod'; + +import buildBlockConfigurationSchema from '../../src/builders/buildBlockConfigurationSchema'; + +describe('builders/buildBlockConfigurationSchema', () => { + it('builds a BlockConfiguration schema with an id, data, and type', () => { + const blockConfigurationSchema = buildBlockConfigurationSchema({ + SampleBlock: { + schema: z.object({ text: z.string() }), + Component: ({ text }) =>
{text.toUpperCase()}
, + }, + }); + const parsedData = blockConfigurationSchema.safeParse({ + id: 'my id', + type: 'SampleBlock', + data: { text: 'Test text!' }, + }); + expect(parsedData).toEqual({ + success: true, + data: { + id: 'my id', + type: 'SampleBlock', + data: { text: 'Test text!' }, + }, + }); + }); +}); diff --git a/tests/builder/buildDocumentEditor.spec.tsx b/tests/builder/buildDocumentEditor.spec.tsx new file mode 100644 index 0000000..061d7af --- /dev/null +++ b/tests/builder/buildDocumentEditor.spec.tsx @@ -0,0 +1,83 @@ +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 { 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('#useBlockState', () => { + it('returns a getter and a setter tuple', () => { + let value: any; + let setValue: any; + const ViewBlockConfig = ({ id }: { id: string }) => { + const tuple = useBlockState(id); + value = tuple[0]; + setValue = tuple[1]; + return ( +
+            {tuple[0].type} - {tuple[0].data.text}
+          
+ ); + }; + + expect( + render( + + + + ).queryAllByText('SampleBlock - Test text!') + ).toHaveLength(1); + + act(() => { + setValue({ + id: 'my id', + type: 'SampleBlock' as const, + data: { text: 'changed text?' }, + }); + }); + + expect( + render( + + + + ).queryAllByText('SampleBlock - changed text?') + ).toHaveLength(1); + expect(value).toEqual({ + id: 'my id', + type: 'SampleBlock', + data: { + text: 'Test 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 new file mode 100644 index 0000000..3d3af12 --- /dev/null +++ b/tests/builder/buildDocumentReader.spec.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { z } from 'zod'; + +import { render } from '@testing-library/react'; + +import buildDocumentReader from '../../src/builders/buildDocumentReader'; + +describe('builders/buildDocumentReader', () => { + const { DocumentReaderProvider, Block, useBlock } = buildDocumentReader({ + 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('#useBlock', () => { + it('gets the value given an id', () => { + const ViewBlockConfig = ({ id }: { id: string }) => { + const c = useBlock(id); + return
{JSON.stringify(c)}
; + }; + expect( + render( + + + + ).queryAllByText('{"id":"my id","type":"SampleBlock","data":{"text":"Test text!"}}') + ).toHaveLength(1); + }); + }); + + describe('#Block', () => { + it('renders the component from the BlocksConfiguration', () => { + expect( + render( + + + + ).queryAllByText('TEST TEXT!') + ).toHaveLength(1); + }); + }); +}); diff --git a/tests/builder/index.spec.tsx b/tests/builder/index.spec.tsx deleted file mode 100644 index a648d42..0000000 --- a/tests/builder/index.spec.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { z } from 'zod'; - -import { render } from '@testing-library/react'; - -import { buildBlockComponent, buildBlockConfigurationSchema } from '../../src/builders'; - -describe('builders', () => { - describe('buildBlockComponent', () => { - it('renders the specified component', () => { - const BlockComponent = buildBlockComponent({ - SampleBlock: { - schema: z.object({ text: z.string() }), - Component: ({ text }) =>
{text.toUpperCase()}
, - }, - }); - expect( - render().asFragment() - ).toMatchSnapshot(); - }); - }); - - describe('buildBlockConfigurationSchema', () => { - it('adds an id, data, and type to the provided schema', () => { - const blockConfigurationSchema = buildBlockConfigurationSchema({ - SampleBlock: { - schema: z.object({ text: z.string() }), - Component: ({ text }) =>
{text.toUpperCase()}
, - }, - }); - - const sampleValidData = { - id: 'my id', - type: 'SampleBlock', - data: { text: 'Test text!' }, - }; - const parsedData = blockConfigurationSchema.safeParse(sampleValidData); - expect(parsedData).toEqual({ - success: true, - data: { - id: 'my id', - type: 'SampleBlock', - data: { text: 'Test text!' }, - }, - }); - }); - }); -});