From c825b96b0e73982a414d44614006ae0a272a6e3f Mon Sep 17 00:00:00 2001 From: Bart van den Aardweg Date: Thu, 1 Dec 2022 17:32:17 +0100 Subject: [PATCH 1/3] feat: try to infer blockstate types --- .../src/blocks/CustomBlock/block.tsx | 2 +- .../src/blocks/CustomBlock/index.tsx | 2 +- .../src/blocks/HeroBlock/index.tsx | 20 +++++++++---- packages/client/package.json | 6 +++- packages/client/src/defineBlock.ts | 29 ++++++++++++++----- packages/client/src/hooks/useBlockState.ts | 12 ++++++-- yarn.lock | 12 ++++---- 7 files changed, 59 insertions(+), 24 deletions(-) diff --git a/examples/block-extension/src/blocks/CustomBlock/block.tsx b/examples/block-extension/src/blocks/CustomBlock/block.tsx index b8f4012..666c736 100644 --- a/examples/block-extension/src/blocks/CustomBlock/block.tsx +++ b/examples/block-extension/src/blocks/CustomBlock/block.tsx @@ -7,7 +7,7 @@ import { useShopifyClient, useTheme, useToast, -} from 'instant-client'; +} from 'instant-client/src'; const Box = ({ children, diff --git a/examples/block-extension/src/blocks/CustomBlock/index.tsx b/examples/block-extension/src/blocks/CustomBlock/index.tsx index 4b7f2cb..0b990cc 100644 --- a/examples/block-extension/src/blocks/CustomBlock/index.tsx +++ b/examples/block-extension/src/blocks/CustomBlock/index.tsx @@ -1,4 +1,4 @@ -import { defineBlock } from 'instant-client'; +import { defineBlock } from 'instant-client/src'; import { CustomBlock } from './block'; import '../test.css'; import './styles.css'; diff --git a/examples/block-extension/src/blocks/HeroBlock/index.tsx b/examples/block-extension/src/blocks/HeroBlock/index.tsx index 90a67fd..4bf5505 100644 --- a/examples/block-extension/src/blocks/HeroBlock/index.tsx +++ b/examples/block-extension/src/blocks/HeroBlock/index.tsx @@ -1,8 +1,15 @@ -import { defineBlock, useBlockState } from 'instant-client'; +import { + defineBlock, + useBlockState, + InferBlockState, +} from 'instant-client/src'; import './index.css'; -const HeroBlock = () => { - const { content } = useBlockState(); +const Hero = () => { + const { content } = useBlockState>(); + type Test = NonNullable< + typeof HeroBlock['contentSchema'] + >['fields'][number]['name']; return (
@@ -11,14 +18,17 @@ const HeroBlock = () => { ); }; -export default defineBlock({ - component: HeroBlock, +const HeroBlock = defineBlock({ + component: Hero, customizerSchema: { fields: [{ type: 'text', name: 'Test color' }], }, contentSchema: { fields: [ { type: 'text', name: 'title', label: 'Title', preview: 'Hero title' }, + { type: 'select', name: 'select', options: [{ key: '', value: '' }] }, ], }, }); + +export default HeroBlock; diff --git a/packages/client/package.json b/packages/client/package.json index 5f42dc3..d8d9599 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -7,6 +7,9 @@ ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./src": { + "import": "./src/index.ts" } }, "main": "./dist/index.cjs", @@ -26,6 +29,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "tsconfig": "*", + "ts-toolbelt": "9.6.0", "types": "*" }, "devDependencies": { @@ -33,6 +37,6 @@ "@types/react-dom": "18.0.1", "@vitejs/plugin-react": "2.0.0", "tsup": "6.5.0", - "typescript": "4.3.5" + "typescript": "4.9.3" } } diff --git a/packages/client/src/defineBlock.ts b/packages/client/src/defineBlock.ts index 6091329..12b9094 100644 --- a/packages/client/src/defineBlock.ts +++ b/packages/client/src/defineBlock.ts @@ -1,5 +1,7 @@ import { createElement, ReactElement, ReactNode } from 'react'; import { render as remoteRender, RemoteRoot } from '@remote-ui/react'; +import { O } from 'ts-toolbelt'; +import { Narrow } from 'ts-toolbelt/out/Function/Narrow'; /** This relative import forces types from this package to be included in this bundle */ import { DefineContentSchema, @@ -24,7 +26,7 @@ function render( (self as any).render(callback, contentSchema, customizerSchema); } -interface DefineBlockParams { +type DefineBlockParams = { /** React component rendered by the block */ component: () => RenderElement | ReactElement; preview?: { @@ -35,14 +37,16 @@ interface DefineBlockParams { customizerSchema?: DefineCustomizerSchema; /** The content schema that is synced to the CMS */ contentSchema?: DefineContentSchema; -} +}; + +export const defineBlock = (params: DefineBlockParams) => { + const { + component, + preview: { decorators } = {}, + customizerSchema, + contentSchema, + } = params; -export const defineBlock = ({ - component, - preview: { decorators } = {}, - customizerSchema, - contentSchema, -}: DefineBlockParams) => { render( (root, blockProps) => { const renderedComponent = @@ -64,4 +68,13 @@ export const defineBlock = ({ contentSchema, customizerSchema, ); + + return params; }; + +export interface InferBlockState> { + content: { + [K in NonNullable['fields'][number]['name']]: string; + }; + customizations: any; +} diff --git a/packages/client/src/hooks/useBlockState.ts b/packages/client/src/hooks/useBlockState.ts index 778f72a..ec480eb 100644 --- a/packages/client/src/hooks/useBlockState.ts +++ b/packages/client/src/hooks/useBlockState.ts @@ -1,7 +1,15 @@ import { useBlockContext } from '../BlockProvider'; -export function useBlockState() { +export function useBlockState< + T extends { content: any; customizations: any } = { + content: any; + customizations: any; + }, +>() { const { content, customizations } = useBlockContext(); - return { content, customizations }; + return { content, customizations } as { + content: T['content']; + customizations: T['customizations']; + }; } diff --git a/yarn.lock b/yarn.lock index 33f1605..4441998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12304,6 +12304,11 @@ ts-node@^10.8.1: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +ts-toolbelt@9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" + integrity sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w== + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" @@ -12530,11 +12535,6 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.3.5: - version "4.3.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" - integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== - typescript@4.6.4: version "4.6.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" @@ -12545,7 +12545,7 @@ typescript@4.8.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== -typescript@^4.2.4, typescript@^4.6.4: +typescript@4.9.3, typescript@^4.2.4, typescript@^4.6.4: version "4.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db" integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA== From ebbac2a17cc02e9a7694ed69bec6de1ed767d739 Mon Sep 17 00:00:00 2001 From: Bart van den Aardweg Date: Wed, 11 Jan 2023 15:45:46 +0100 Subject: [PATCH 2/3] feat(client): narrow inferred blockstate type --- .../src/blocks/HeroBlock/index.tsx | 12 ++++++++---- packages/client/src/defineBlock.ts | 17 +++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/examples/block-extension/src/blocks/HeroBlock/index.tsx b/examples/block-extension/src/blocks/HeroBlock/index.tsx index cfc4ccf..cc393c1 100644 --- a/examples/block-extension/src/blocks/HeroBlock/index.tsx +++ b/examples/block-extension/src/blocks/HeroBlock/index.tsx @@ -1,8 +1,12 @@ -import { defineBlock, useBlockState } from '@instantcommerce/sdk'; +import { + defineBlock, + InferBlockState, + useBlockState, +} from '@instantcommerce/sdk'; import './index.css'; const Hero = () => { - const { content } = useBlockState(); + const { content } = useBlockState>(); return (
@@ -17,12 +21,12 @@ const HeroBlock = defineBlock({ fields: { color: { type: 'text' }, }, - }, + } as const, contentSchema: { fields: { title: { type: 'text', label: 'Title', preview: 'Hero title' }, }, - }, + } as const, }); export default HeroBlock; diff --git a/packages/client/src/defineBlock.ts b/packages/client/src/defineBlock.ts index 12b9094..a00a81a 100644 --- a/packages/client/src/defineBlock.ts +++ b/packages/client/src/defineBlock.ts @@ -1,6 +1,5 @@ import { createElement, ReactElement, ReactNode } from 'react'; import { render as remoteRender, RemoteRoot } from '@remote-ui/react'; -import { O } from 'ts-toolbelt'; import { Narrow } from 'ts-toolbelt/out/Function/Narrow'; /** This relative import forces types from this package to be included in this bundle */ import { @@ -39,13 +38,13 @@ type DefineBlockParams = { contentSchema?: DefineContentSchema; }; -export const defineBlock = (params: DefineBlockParams) => { +export const defineBlock = (params: Narrow) => { const { component, preview: { decorators } = {}, customizerSchema, contentSchema, - } = params; + } = params as DefineBlockParams; render( (root, blockProps) => { @@ -72,9 +71,11 @@ export const defineBlock = (params: DefineBlockParams) => { return params; }; -export interface InferBlockState> { - content: { - [K in NonNullable['fields'][number]['name']]: string; - }; - customizations: any; +export interface InferBlockState { + content: T['contentSchema'] extends { fields: any } + ? { [name in keyof T['contentSchema']['fields']]: string } + : {}; + customizer: T['customizerSchema'] extends { fields: any } + ? T['customizerSchema']['fields'] + : {}; } From 351284c6412abd00da1911a394f2bd4272238112 Mon Sep 17 00:00:00 2001 From: Bart van den Aardweg Date: Fri, 13 Jan 2023 16:27:43 +0100 Subject: [PATCH 3/3] feat(client): attempt to type all content fields --- .../src/blocks/HeroBlock/index.tsx | 112 ++++++++++++++- packages/client/src/defineBlock.ts | 136 +++++++++++++++++- packages/types/schemas.ts | 35 +++-- 3 files changed, 268 insertions(+), 15 deletions(-) diff --git a/examples/block-extension/src/blocks/HeroBlock/index.tsx b/examples/block-extension/src/blocks/HeroBlock/index.tsx index cc393c1..1301e64 100644 --- a/examples/block-extension/src/blocks/HeroBlock/index.tsx +++ b/examples/block-extension/src/blocks/HeroBlock/index.tsx @@ -6,10 +6,12 @@ import { import './index.css'; const Hero = () => { - const { content } = useBlockState>(); + const { content, customizer } = + useBlockState>(); return (
+ {content.cards![0].value}

{content.title}

); @@ -20,13 +22,117 @@ const HeroBlock = defineBlock({ customizerSchema: { fields: { color: { type: 'text' }, + number: { type: 'number', min: 2, max: 80, fractionDigits: 3 }, + padding: { + type: 'select', + options: [ + { label: 'Small', value: 'sm' }, + { label: 'Large', value: 'lg' }, + ], + preview: 'sm', + }, + textColor: { type: 'color', preview: '#A020F0' }, + text: { type: 'text', maxLength: 1, isRequired: true }, + toggle: { type: 'toggle', preview: true }, }, } as const, contentSchema: { fields: { title: { type: 'text', label: 'Title', preview: 'Hero title' }, - }, - } as const, + select: { + type: 'select', + preview: 'value1', + options: [ + { value: 'value1', label: 'Option 1' }, + { value: 'value2', label: 'Option 2' }, + ], + }, + date: { + type: 'date', + preview: '2022-12-12 08:12', + withTime: true, + }, + image: { + type: 'image', + preview: + 'https://images.unsplash.com/photo-1669962367460-00b711b2e3f3?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=700&q=80', + }, + link: { + type: 'link', + preview: 'https://google.com', + }, + richText: { + type: 'richText', + preview: 'Rich text', + toolbar: [], + }, + cards: { + type: 'subschema', + allowed: ['card'], + max: 3, + preview: [ + { + subschema: 'card', + value: { + cardTitle: 'Card title', + cardImage: + 'https://a.storyblok.com/f/145828/5000x3333/564e281ca1/force-majeure-du8abwm5z2g-unsplash.jpg', + cardButtons: [ + { + subschema: 'button', + value: { + text: 'Button', + link: 'https://a.storyblok.com/f/145828/5000x3333/564e281ca1/force-majeure-du8abwm5z2g-unsplash.jpg', + }, + }, + ], + }, + }, + ], + }, + } as const, + subschemas: { + button: { + fields: { + text: { + type: 'text', + label: 'Text', + isTranslatable: true, + isRequired: true, + maxLength: 40, + }, + link: { + type: 'link', + label: 'Link', + isTranslatable: true, + isRequired: true, + }, + }, + }, + card: { + fields: { + cardImage: { + type: 'image', + label: 'Image', + isRequired: true, + }, + cardTitle: { + type: 'text', + label: 'Title', + isTranslatable: true, + isRequired: true, + }, + cardButtons: { + type: 'subschema', + label: 'Buttons', + isRequired: false, + max: 2, + allowed: ['button'], + }, + }, + }, + } as const, + }, }); export default HeroBlock; diff --git a/packages/client/src/defineBlock.ts b/packages/client/src/defineBlock.ts index a00a81a..277a836 100644 --- a/packages/client/src/defineBlock.ts +++ b/packages/client/src/defineBlock.ts @@ -71,11 +71,143 @@ export const defineBlock = (params: Narrow) => { return params; }; +export interface AssetStoryblok { + alt?: string; + copyright?: string; + id: number; + filename: string; + name: string; + title?: string; + focus?: string; + [k: string]: any; +} + +export type LinkStoryblok = + | { + cached_url?: string; + linktype?: string; + [k: string]: any; + } + | { + id?: string; + cached_url?: string; + linktype?: 'story'; + [k: string]: any; + } + | { + url?: string; + cached_url?: string; + linktype?: 'asset' | 'url'; + [k: string]: any; + } + | { + email?: string; + linktype?: 'email'; + [k: string]: any; + }; + +export interface RichTextStoryblok { + content?: RichTextStoryblok[]; + marks?: RichTextStoryblok[]; + attrs?: any; + text?: string; + type: string; +} + +type ContentFieldValues< + C extends DefineContentSchema< + C['subschemas'] extends { subschemas: any } ? C['subschemas'] : {} + >, + T extends C['fields'][keyof C['fields']], + S extends C['subschemas'], +> = T extends { + type: 'image'; +} + ? AssetStoryblok + : T extends { + type: 'link'; + } + ? LinkStoryblok + : T extends { + type: 'richText'; + } + ? RichTextStoryblok + : T extends { + type: 'select'; + } + ? T['options'][number]['value'] + : T extends { + type: 'subschema'; + } + ? ContentSubschema + : string; + +type ContentSubschema< + C extends DefineContentSchema< + C['subschemas'] extends { subschemas: any } ? C['subschemas'] : {} + >, + T extends C['fields'][keyof C['fields']], + S extends C['subschemas'], + A extends keyof S = keyof S, +> = { + subschema: A; + value: ContentSubschemaValues; +}[]; + +type ContentSubschemaValues< + C extends DefineContentSchema< + C['subschemas'] extends { subschemas: any } ? C['subschemas'] : {} + >, + T extends C['fields'][keyof C['fields']], + S extends C['subschemas'], + A extends keyof S, + B extends keyof S[A] = keyof S[A], + D = S[A][B], +> = { + [K in keyof D]: D[K]; // ContentFieldValues; +}; + +type ContentFields< + T extends DefineContentSchema< + T['subschemas'] extends { subschemas: any } ? T['subschemas'] : {} + >, +> = { + [name in keyof T['fields']]: T['fields'][name] extends { + isRequired: true; + } + ? ContentFieldValues + : ContentFieldValues | undefined; +}; + +type CustomizerFieldValues< + T extends DefineCustomizerSchema['fields'][keyof DefineCustomizerSchema['fields']], +> = T extends { + type: 'number'; +} + ? number + : T extends { + type: 'toggle'; + } + ? boolean + : T extends { + type: 'select'; + } + ? T['options'][number]['value'] + : string; + +type CustomizerFields = { + [name in keyof T]: T[name] extends { + isRequired: true; + } + ? CustomizerFieldValues + : CustomizerFieldValues | undefined; +}; + export interface InferBlockState { content: T['contentSchema'] extends { fields: any } - ? { [name in keyof T['contentSchema']['fields']]: string } + ? ContentFields : {}; customizer: T['customizerSchema'] extends { fields: any } - ? T['customizerSchema']['fields'] + ? CustomizerFields : {}; } diff --git a/packages/types/schemas.ts b/packages/types/schemas.ts index 92c2598..a355533 100644 --- a/packages/types/schemas.ts +++ b/packages/types/schemas.ts @@ -13,6 +13,7 @@ import { CustomizerSchemaNumberField, CustomizerSchemaColorField, ContentSchemaSubschemaField, + SelectOption, } from './api'; type EnhancedContentSchemaField< @@ -39,17 +40,25 @@ type ContentLinkField = EnhancedContentSchemaField< >; type ContentRichTextField = EnhancedContentSchemaField< - ContentSchemaRichTextField, + Omit & { + toolbar: Array | ReadonlyArray; + }, 'richText' >; type ContentSelectField = EnhancedContentSchemaField< - ContentSchemaSelectField, + Omit & { + options: Array | ReadonlyArray; + }, 'select' >; -type ContentSubschemaField = EnhancedContentSchemaField< - ContentSchemaSubschemaField, +type ContentSubschemaField< + Subschemas extends Record, +> = EnhancedContentSchemaField< + Omit & { + allowed: Array | ReadonlyArray; + }, 'subschema' >; @@ -58,12 +67,14 @@ type ContentTextField = EnhancedContentSchemaField< 'text' >; -export type ContentSchemaInputField = +export type ContentSchemaInputField< + Subschemas extends Record = {}, +> = | ContentDateField | ContentImageField | ContentRichTextField | ContentSelectField - | ContentSubschemaField + | ContentSubschemaField | ContentTextField | ContentLinkField; @@ -71,9 +82,11 @@ type ContentSubschema = Pick & { fields: Record; }; -export interface DefineContentSchema { - fields: Record; - subschemas?: Record; +export interface DefineContentSchema< + Subschemas extends Record = {}, +> { + fields: Record>; + subschemas?: Subschemas; } type EnhancedCustomizerSchemaField< @@ -97,7 +110,9 @@ type CustomizerNumberField = EnhancedCustomizerSchemaField< >; type CustomizerSelectField = EnhancedCustomizerSchemaField< - CustomizerSchemaSelectField, + Omit & { + options: Array | ReadonlyArray; + }, 'select' >;