diff --git a/.changeset/add-display-only-field.md b/.changeset/add-display-only-field.md new file mode 100644 index 0000000000..e89a30093b --- /dev/null +++ b/.changeset/add-display-only-field.md @@ -0,0 +1,7 @@ +--- +"@tinacms/schema-tools": patch +"@tinacms/graphql": patch +"tinacms": patch +--- + +Add 'displayOnly' field type for display-only form fields diff --git a/.changeset/fix-code-block-meta-preservation.md b/.changeset/fix-code-block-meta-preservation.md new file mode 100644 index 0000000000..958b7e81b9 --- /dev/null +++ b/.changeset/fix-code-block-meta-preservation.md @@ -0,0 +1,5 @@ +--- +"@tinacms/mdx": patch +--- + +Preserve code block infostring meta through parse/serialize round-trip. Metadata after the language identifier (e.g. `{1,3-5}` or `title="app.py"`) was silently dropped on every save; it is now captured in the parser and emitted by the serializer. diff --git a/packages/@tinacms/graphql/src/builder/index.ts b/packages/@tinacms/graphql/src/builder/index.ts index 06bce24765..14e24e4d3a 100644 --- a/packages/@tinacms/graphql/src/builder/index.ts +++ b/packages/@tinacms/graphql/src/builder/index.ts @@ -596,6 +596,8 @@ export class Builder { depth: number ) => Promise = async (field, depth) => { switch (field.type) { + case 'displayOnly': + return false; case 'string': case 'image': case 'datetime': @@ -1098,6 +1100,8 @@ export class Builder { private _buildFieldFilter = async (field: TinaField) => { switch (field.type) { + case 'displayOnly': + return undefined; case 'boolean': return astBuilder.InputValueDefinition({ name: field.name, @@ -1260,6 +1264,8 @@ export class Builder { private _buildFieldMutation = async (field: TinaField) => { switch (field.type) { + case 'displayOnly': + return undefined; case 'boolean': return astBuilder.InputValueDefinition({ name: field.name, @@ -1532,6 +1538,8 @@ Visit https://tina.io/docs/r/content-fields/#list-fields/ for more information `; switch (field.type) { + case 'displayOnly': + return undefined; case 'boolean': case 'datetime': case 'number': diff --git a/packages/@tinacms/graphql/src/resolver/index.ts b/packages/@tinacms/graphql/src/resolver/index.ts index c3575f4f88..d49e2c4a14 100644 --- a/packages/@tinacms/graphql/src/resolver/index.ts +++ b/packages/@tinacms/graphql/src/resolver/index.ts @@ -62,6 +62,8 @@ const resolveFieldData = async ( assertShape<{ [key: string]: unknown }>(rawData, (yup) => yup.object()); const value = rawData[field.name]; switch (field.type) { + case 'displayOnly': + break; case 'datetime': // See you in March ;) if (value instanceof Date) { @@ -1537,6 +1539,8 @@ export class Resolver { throw new Error(`Expected to find field by name ${fieldName}`); } switch (field.type) { + case 'displayOnly': + break; case 'datetime': // @ts-ignore FIXME: Argument of type 'string | { [key: string]: unknown; } | (string | { [key: string]: unknown; })[]' is not assignable to parameter of type 'string' accum[fieldName] = resolveDateInput(fieldValue, field); diff --git a/packages/@tinacms/graphql/src/schema/validate.test.ts b/packages/@tinacms/graphql/src/schema/validate.test.ts index d7c7e649ac..d6111a4d39 100644 --- a/packages/@tinacms/graphql/src/schema/validate.test.ts +++ b/packages/@tinacms/graphql/src/schema/validate.test.ts @@ -66,7 +66,7 @@ describe('The schema validation', () => { ], }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"'type' must be one of: string, number, boolean, datetime, image, reference, object, rich-text, password, but got 'some-type' at someName.myTitle"` + `"'type' must be one of: string, number, boolean, datetime, image, reference, object, rich-text, password, displayOnly, but got 'some-type' at someName.myTitle"` ); }); diff --git a/packages/@tinacms/graphql/src/schema/validate.ts b/packages/@tinacms/graphql/src/schema/validate.ts index 7e9d35ade5..39bee69d93 100644 --- a/packages/@tinacms/graphql/src/schema/validate.ts +++ b/packages/@tinacms/graphql/src/schema/validate.ts @@ -21,6 +21,7 @@ const FIELD_TYPES: TinaField['type'][] = [ 'object', 'rich-text', 'password', + 'displayOnly', ]; export const validateSchema = async (schema: Schema) => { diff --git a/packages/@tinacms/mdx/src/next/stringify/pre-processing.ts b/packages/@tinacms/mdx/src/next/stringify/pre-processing.ts index 2039250b67..123df920ce 100644 --- a/packages/@tinacms/mdx/src/next/stringify/pre-processing.ts +++ b/packages/@tinacms/mdx/src/next/stringify/pre-processing.ts @@ -81,6 +81,7 @@ export const blockElement = ( return { type: 'code', lang: content.lang, + meta: content.meta, value: codeLinesToString(content.children).join('\n'), }; case 'mdxJsxFlowElement': diff --git a/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/field.ts b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/field.ts new file mode 100644 index 0000000000..2586315fd4 --- /dev/null +++ b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/field.ts @@ -0,0 +1,7 @@ +import { RichTextField } from '@tinacms/schema-tools'; + +export const field: RichTextField = { + name: 'body', + type: 'rich-text', + parser: { type: 'markdown', skipEscaping: 'html' }, +}; diff --git a/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/in.md b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/in.md new file mode 100644 index 0000000000..f34215eb47 --- /dev/null +++ b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/in.md @@ -0,0 +1,7 @@ +```js {1,3-5} +console.log('hello'); +``` + +```python title="app.py" +print("hello") +``` diff --git a/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/index.test.ts b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/index.test.ts new file mode 100644 index 0000000000..87583502a2 --- /dev/null +++ b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/index.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest'; +import { parseMDX } from '../../../parse'; +import { serializeMDX } from '../../../stringify'; +import * as util from '../util'; +import { field } from './field'; +import input from './in.md?raw'; + +it('preserves code block meta (infostring) through parse/serialize round-trip', () => { + const tree = parseMDX(input, field, (v) => v); + expect(util.print(tree)).toMatchFile(util.nodePath(__dirname)); + const string = serializeMDX(tree, field, (v) => v); + expect(string).toMatchFile(util.mdPath(__dirname)); +}); diff --git a/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/node.json b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/node.json new file mode 100644 index 0000000000..9fc94beae4 --- /dev/null +++ b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/node.json @@ -0,0 +1,37 @@ +{ + "type": "root", + "children": [ + { + "type": "code_block", + "lang": "js", + "meta": "{1,3-5}", + "value": "console.log('hello');", + "children": [ + { + "type": "code_line", + "children": [ + { + "text": "console.log('hello');" + } + ] + } + ] + }, + { + "type": "code_block", + "lang": "python", + "meta": "title=\"app.py\"", + "value": "print(\"hello\")", + "children": [ + { + "type": "code_line", + "children": [ + { + "text": "print(\"hello\")" + } + ] + } + ] + } + ] +} diff --git a/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/out.md b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/out.md new file mode 100644 index 0000000000..f34215eb47 --- /dev/null +++ b/packages/@tinacms/mdx/src/next/tests/markdown-basic-code-block-meta/out.md @@ -0,0 +1,7 @@ +```js {1,3-5} +console.log('hello'); +``` + +```python title="app.py" +print("hello") +``` diff --git a/packages/@tinacms/mdx/src/parse/plate.ts b/packages/@tinacms/mdx/src/parse/plate.ts index 6299e83985..cbd8425d57 100644 --- a/packages/@tinacms/mdx/src/parse/plate.ts +++ b/packages/@tinacms/mdx/src/parse/plate.ts @@ -28,6 +28,7 @@ export type CodeLineElement = { export type CodeBlockElement = { type: 'code_block'; lang?: string; + meta?: string; value?: string; // this is needed for mdast, it is not used by Plate as the 'value' (platev48) now stores this in the children as CodeLineElements children: CodeLineElement[]; }; diff --git a/packages/@tinacms/mdx/src/parse/remarkToPlate.ts b/packages/@tinacms/mdx/src/parse/remarkToPlate.ts index c3f780a99d..892eb77e62 100644 --- a/packages/@tinacms/mdx/src/parse/remarkToPlate.ts +++ b/packages/@tinacms/mdx/src/parse/remarkToPlate.ts @@ -296,6 +296,7 @@ export const remarkToSlate = ( const code = (content: Md.Code): Plate.CodeBlockElement => { const extra: Record = {}; if (content.lang) extra['lang'] = content.lang; + if (content.meta) extra['meta'] = content.meta; const value = content.value ?? ''; const children = diff --git a/packages/@tinacms/mdx/src/stringify/index.ts b/packages/@tinacms/mdx/src/stringify/index.ts index a5dfc0cbf4..c9448fab3a 100644 --- a/packages/@tinacms/mdx/src/stringify/index.ts +++ b/packages/@tinacms/mdx/src/stringify/index.ts @@ -210,6 +210,7 @@ export const blockElement = ( return { type: 'code', lang: content.lang, + meta: content.meta, value: codeLinesToString(content), }; case 'mdxJsxFlowElement': diff --git a/packages/@tinacms/schema-tools/src/schema/resolveField.ts b/packages/@tinacms/schema-tools/src/schema/resolveField.ts index f4d61db6ac..87e26fd37e 100644 --- a/packages/@tinacms/schema-tools/src/schema/resolveField.ts +++ b/packages/@tinacms/schema-tools/src/schema/resolveField.ts @@ -187,6 +187,12 @@ export const resolveField = ( component: 'reference', ...extraFields, }; + case 'displayOnly': + return { + component: 'displayOnly', + ...field, + ...extraFields, + }; default: // @ts-ignore throw new Error(`Unknown field type ${field.type}`); diff --git a/packages/@tinacms/schema-tools/src/types/index.ts b/packages/@tinacms/schema-tools/src/types/index.ts index 4e12adae4f..b9aed5bd40 100644 --- a/packages/@tinacms/schema-tools/src/types/index.ts +++ b/packages/@tinacms/schema-tools/src/types/index.ts @@ -333,6 +333,16 @@ export type PasswordField = ( type: 'password'; }; +export type DisplayOnlyField = BaseField & { + type: 'displayOnly'; + list?: never; + required?: never; + indexed?: never; + ui?: { + component?: FC | null; + }; +}; + export type ToolbarOverrideType = | 'heading' | 'link' @@ -504,6 +514,7 @@ type Field = ( | RichTextField | ObjectField | PasswordField + | DisplayOnlyField ) & MaybeNamespace; diff --git a/packages/@tinacms/schema-tools/src/validate/fields.ts b/packages/@tinacms/schema-tools/src/validate/fields.ts index 75467d775b..c7df7fbf5d 100644 --- a/packages/@tinacms/schema-tools/src/validate/fields.ts +++ b/packages/@tinacms/schema-tools/src/validate/fields.ts @@ -16,6 +16,7 @@ const TypeName = [ 'object', 'reference', 'rich-text', + 'displayOnly', ] as const; const formattedTypes = ` - ${TypeName.join('\n - ')}`; @@ -96,6 +97,40 @@ const DateTimeField = TinaScalerBase.extend({ timeFormat: z.string().optional(), }); +// ========== +// Display-only fields +// ========== +const DisplayOnlyField = TinaField.omit({ required: true }).extend({ + type: z.literal('displayOnly', { + invalid_type_error: typeTypeError, + required_error: typeRequiredError, + }), + list: z + .literal(undefined, { + errorMap: () => ({ + message: + 'Property `list` is not allowed on fields of `type: displayOnly`.', + }), + }) + .optional(), + required: z + .literal(undefined, { + errorMap: () => ({ + message: + 'Property `required` is not allowed on fields of `type: displayOnly`.', + }), + }) + .optional(), + indexed: z + .literal(undefined, { + errorMap: () => ({ + message: + 'Property `indexed` is not allowed on fields of `type: displayOnly`.', + }), + }) + .optional(), +}); + // ========== // Non Scaler fields // ========== @@ -199,6 +234,7 @@ export const TinaFieldZod: z.ZodType = z.lazy(() => { ObjectField, RichTextField, PasswordField, + DisplayOnlyField, ], { errorMap: (issue, ctx) => { diff --git a/packages/tinacms/src/toolkit/fields/plugins/display-only-field-plugin.tsx b/packages/tinacms/src/toolkit/fields/plugins/display-only-field-plugin.tsx new file mode 100644 index 0000000000..031027a242 --- /dev/null +++ b/packages/tinacms/src/toolkit/fields/plugins/display-only-field-plugin.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { wrapFieldWithNoHeader } from './wrap-field-with-meta'; + +const DefaultDisplayOnlyField: React.FC = () => { + return ( +
+ Display-only field — provide a component via ui.component +
+ ); +}; + +export const DisplayOnlyFieldPlugin = { + name: 'displayOnly', + Component: wrapFieldWithNoHeader(DefaultDisplayOnlyField), +}; + +export const InfoBox = ({ + message, + links, +}: { + message: string; + links?: { text: string; url: string }[]; +}): React.FC => { + const InfoBoxComponent: React.FC = () => { + return ( +
+
+

+ {message} +

+ {links && links.length > 0 && ( + + )} +
+
+ ); + }; + InfoBoxComponent.displayName = 'InfoBox'; + return InfoBoxComponent; +}; diff --git a/packages/tinacms/src/toolkit/fields/plugins/index.ts b/packages/tinacms/src/toolkit/fields/plugins/index.ts index c7aa50ab2b..b603a7d69d 100644 --- a/packages/tinacms/src/toolkit/fields/plugins/index.ts +++ b/packages/tinacms/src/toolkit/fields/plugins/index.ts @@ -18,3 +18,4 @@ export * from './reference-field-plugin'; export * from './button-toggle-field-plugin'; export * from './hidden-field-plugin'; export * from './password-field-plugin'; +export * from './display-only-field-plugin'; diff --git a/packages/tinacms/src/toolkit/tina-cms.ts b/packages/tinacms/src/toolkit/tina-cms.ts index 4b0024892e..4a352813cd 100644 --- a/packages/tinacms/src/toolkit/tina-cms.ts +++ b/packages/tinacms/src/toolkit/tina-cms.ts @@ -12,6 +12,7 @@ import { CheckboxGroupFieldPlugin, ColorFieldPlugin, DateFieldPlugin, + DisplayOnlyFieldPlugin, GroupFieldPlugin, GroupListFieldPlugin, HiddenFieldPlugin, @@ -65,6 +66,7 @@ const DEFAULT_FIELDS = [ ButtonToggleFieldPlugin, HiddenFieldPlugin, PasswordFieldPlugin, + DisplayOnlyFieldPlugin, ]; export interface TinaCMSConfig extends CMSConfig {