diff --git a/demo/admin/src/products/future/ProductForm.cometGen.ts b/demo/admin/src/products/future/ProductForm.cometGen.ts index 24228497be..7a05f2e5c1 100644 --- a/demo/admin/src/products/future/ProductForm.cometGen.ts +++ b/demo/admin/src/products/future/ProductForm.cometGen.ts @@ -7,20 +7,39 @@ export const ProductForm: FormConfig = { fragmentName: "ProductFormDetails", // configurable as it must be unique across project fields: [ { - type: "text", - name: "title", - label: "Titel", // default is generated from name (camelCaseToHumanReadable) - required: true, // default is inferred from gql schema - validate: { name: "validateTitle", import: "./validateTitle" }, + type: "fieldSet", + name: "mainData", + title: "Main Data", + supportText: "Product: {title}", + collapsible: false, + initiallyExpanded: true, + fields: [ + { + type: "text", + name: "title", + label: "Titel", // default is generated from name (camelCaseToHumanReadable) + required: true, // default is inferred from gql schema + validate: { name: "validateTitle", import: "./validateTitle" }, + }, + { type: "text", name: "slug" }, + { type: "date", name: "createdAt", label: "Created", readOnly: true }, + { type: "text", name: "description", label: "Description", multiline: true }, + { type: "staticSelect", name: "type", label: "Type", required: true /*, values: from gql schema (TODO overridable)*/ }, + { type: "asyncSelect", name: "category", rootQuery: "productCategories" }, + ], + }, + { + type: "fieldSet", + name: "additionalData", + title: "Additional Data", + collapsible: true, + initiallyExpanded: false, + fields: [ + { type: "boolean", name: "inStock" }, + { type: "date", name: "availableSince" }, + { type: "block", name: "image", label: "Image", block: { name: "DamImageBlock", import: "@comet/cms-admin" } }, + ], }, - { type: "text", name: "slug" }, - { type: "date", name: "createdAt", label: "Created", readOnly: true }, - { type: "text", name: "description", label: "Description", multiline: true }, - { type: "staticSelect", name: "type", label: "Type", required: true /*, values: from gql schema (TODO overridable)*/ }, - { type: "asyncSelect", name: "category", rootQuery: "productCategories" }, - { type: "boolean", name: "inStock" }, - { type: "date", name: "availableSince" }, - { type: "block", name: "image", label: "Image", block: { name: "DamImageBlock", import: "@comet/cms-admin" } }, ], }; diff --git a/demo/admin/src/products/future/generated/ProductForm.tsx b/demo/admin/src/products/future/generated/ProductForm.tsx index b6089059f9..fa9b6d1aff 100644 --- a/demo/admin/src/products/future/generated/ProductForm.tsx +++ b/demo/admin/src/products/future/generated/ProductForm.tsx @@ -4,6 +4,7 @@ import { gql, useApolloClient, useQuery } from "@apollo/client"; import { AsyncSelectField, Field, + FieldSet, filterByFragment, FinalForm, FinalFormCheckbox, @@ -24,6 +25,7 @@ import { FormControlLabel, InputAdornment, MenuItem } from "@mui/material"; import { FormApi } from "final-form"; import isEqual from "lodash.isequal"; import React from "react"; +import { FormSpy } from "react-final-form"; import { FormattedMessage } from "react-intl"; import { validateTitle } from "../validateTitle"; @@ -139,90 +141,111 @@ export function ProductForm({ id }: FormProps): React.ReactElement { <> {saveConflict.dialogs} - } - validate={validateTitle} - /> - - } /> - - - - +
} + supportText={ + mode === "edit" && ( + + {({ values }) => ( + + )} + + ) } - fullWidth - name="createdAt" - component={FinalFormDatePicker} - label={} - /> - - } - /> - }> - {(props) => ( - - - - - - - - - - - - )} - - } - loadOptions={async () => { - const { data } = await client.query({ - query: gql` - query ProductCategoriesSelect { - productCategories { - nodes { - id - title + > + } + validate={validateTitle} + /> + + } /> + + + + + } + fullWidth + name="createdAt" + component={FinalFormDatePicker} + label={} + /> + + } + /> + }> + {(props) => ( + + + + + + + + + + + + )} + + } + loadOptions={async () => { + const { data } = await client.query({ + query: gql` + query ProductCategoriesSelect { + productCategories { + nodes { + id + title + } } } - } - `, - }); - return data.productCategories.nodes; - }} - getOptionLabel={(option) => option.title} - /> - - {(props) => ( - } - control={} - /> - )} - - - } - /> - - {createFinalFormBlock(rootBlocks.image)} - + `, + }); + return data.productCategories.nodes; + }} + getOptionLabel={(option) => option.title} + /> +
+ +
}> + + {(props) => ( + } + control={} + /> + )} + + + } + /> + + {createFinalFormBlock(rootBlocks.image)} + +
)} diff --git a/packages/admin/cms-admin/src/generator/future/generateForm.ts b/packages/admin/cms-admin/src/generator/future/generateForm.ts index 51a5c492fa..c6fab852cc 100644 --- a/packages/admin/cms-admin/src/generator/future/generateForm.ts +++ b/packages/admin/cms-admin/src/generator/future/generateForm.ts @@ -1,8 +1,9 @@ import { IntrospectionQuery } from "graphql"; +import { generateFormLayout } from "./generateForm/generateFormLayout"; import { getForwardedGqlArgs } from "./generateForm/getForwardedGqlArgs"; import { generateFormField } from "./generateFormField"; -import { FormConfig, FormFieldConfig, GeneratorReturn } from "./generator"; +import { FormConfig, FormFieldConfig, GeneratorReturn, isFormFieldConfig, isFormLayoutConfig } from "./generator"; import { findMutationTypeOrThrow } from "./utils/findMutationType"; import { generateImportsCode, Imports } from "./utils/generateImportsCode"; import { isFieldOptional } from "./utils/isFieldOptional"; @@ -40,6 +41,16 @@ export function generateForm( const createMutationType = addMode && findMutationTypeOrThrow(config.createMutation ?? `create${gqlType}`, gqlIntrospection); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formFields = config.fields.reduce[]>((acc, field) => { + if (isFormLayoutConfig(field)) { + acc.push(...field.fields); + } else if (isFormFieldConfig(field)) { + acc.push(field); + } + return acc; + }, []); + const gqlArgs: ReturnType["gqlArgs"] = []; if (createMutationType) { const { @@ -47,7 +58,7 @@ export function generateForm( props: forwardedGqlArgsProps, gqlArgs: forwardedGqlArgs, } = getForwardedGqlArgs({ - fields: config.fields, + fields: formFields, gqlOperation: createMutationType, gqlIntrospection, }); @@ -66,7 +77,7 @@ export function generateForm( const { formPropsTypeCode, formPropsParamsCode } = generateFormPropsCode(props); - const rootBlockFields = config.fields + const rootBlockFields = formFields .filter((field) => field.type == "block") .map((field) => { // map is for ts to infer block type correctly @@ -80,10 +91,10 @@ export function generateForm( }); }); - const numberFields = config.fields.filter((field) => field.type == "number"); - const booleanFields = config.fields.filter((field) => field.type == "boolean"); - const dateFields = config.fields.filter((field) => field.type == "date"); - const readOnlyFields = config.fields.filter((field) => field.readOnly); + const numberFields = formFields.filter((field) => field.type == "number"); + const booleanFields = formFields.filter((field) => field.type == "boolean"); + const dateFields = formFields.filter((field) => field.type == "date"); + const readOnlyFields = formFields.filter((field) => field.readOnly); // eslint-disable-next-line @typescript-eslint/no-explicit-any const isOptional = (fieldConfig: FormFieldConfig) => { @@ -93,18 +104,33 @@ export function generateForm( let hooksCode = ""; let formValueToGqlInputCode = ""; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const generateFormFieldCode = (field: FormFieldConfig) => { + const generated = generateFormField({ gqlIntrospection, baseOutputFilename }, field, config); + for (const name in generated.gqlDocuments) { + gqlDocuments[name] = generated.gqlDocuments[name]; + } + imports.push(...generated.imports); + hooksCode += generated.hooksCode; + formValueToGqlInputCode += generated.formValueToGqlInputCode; + formFragmentFields.push(generated.formFragmentField); + return generated.code; + }; + const formFragmentFields: string[] = []; const fieldsCode = config.fields .map((field) => { - const generated = generateFormField({ gqlIntrospection, baseOutputFilename }, field, config); - for (const name in generated.gqlDocuments) { - gqlDocuments[name] = generated.gqlDocuments[name]; + if (isFormFieldConfig(field)) { + return generateFormFieldCode(field); + } else if (isFormLayoutConfig(field)) { + const formLayoutFieldsCode = field.fields.map((field) => generateFormFieldCode(field)).join("\n"); + const generated = generateFormLayout(field, formLayoutFieldsCode, config); + imports.push(...generated.imports); + formFragmentFields.push(...generated.formFragmentFields); + return generated.code; + } else { + throw new Error("Not supported config"); } - imports.push(...generated.imports); - hooksCode += generated.hooksCode; - formValueToGqlInputCode += generated.formValueToGqlInputCode; - formFragmentFields.push(generated.formFragmentField); - return generated.code; }) .join("\n"); diff --git a/packages/admin/cms-admin/src/generator/future/generateForm/generateFormLayout.ts b/packages/admin/cms-admin/src/generator/future/generateForm/generateFormLayout.ts new file mode 100644 index 0000000000..62b8b17bff --- /dev/null +++ b/packages/admin/cms-admin/src/generator/future/generateForm/generateFormLayout.ts @@ -0,0 +1,53 @@ +import { FormConfig, FormLayoutConfig } from "../generator"; +import { Imports } from "../utils/generateImportsCode"; + +export function generateFormLayout( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: FormLayoutConfig, + fieldsCode: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formConfig: FormConfig, +) { + const gqlType = formConfig.gqlType; + const instanceGqlType = gqlType[0].toLowerCase() + gqlType.substring(1); + + const imports: Imports = []; + let code = ""; + const formFragmentFields: string[] = []; + + if (config.type === "fieldSet") { + imports.push({ name: "FieldSet", importPath: "@comet/admin" }); + const supportPlaceholder = config.supportText?.includes("{"); + if (supportPlaceholder) { + imports.push({ name: "FormSpy", importPath: "react-final-form" }); + } + code = ` +
} + ${ + config.supportText + ? `supportText={ + ${supportPlaceholder ? `mode === "edit" && ({({ values }) => (` : ``} + + ${supportPlaceholder ? `)})` : ``} + }` + : `` + } + > + ${fieldsCode} +
`; + } else { + throw new Error(`Unsupported type`); + } + return { + code, + imports, + formFragmentFields, + }; +} diff --git a/packages/admin/cms-admin/src/generator/future/generator.ts b/packages/admin/cms-admin/src/generator/future/generator.ts index 8fd8fb76fd..64316bd45c 100644 --- a/packages/admin/cms-admin/src/generator/future/generator.ts +++ b/packages/admin/cms-admin/src/generator/future/generator.ts @@ -25,6 +25,24 @@ export type FormFieldConfig = ( | { type: "asyncSelect"; rootQuery: string; labelField?: string } | { type: "block"; block: ImportReference } ) & { name: keyof T; label?: string; required?: boolean; validate?: ImportReference; helperText?: string; readOnly?: boolean }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isFormFieldConfig(arg: any): arg is FormFieldConfig { + return !isFormLayoutConfig(arg); +} + +export type FormLayoutConfig = { + type: "fieldSet"; + name: string; + title: string; + supportText?: string; // can contain field-placeholder + collapsible: boolean; // default true + initiallyExpanded: boolean; // default false + fields: FormFieldConfig[]; +}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isFormLayoutConfig(arg: any): arg is FormLayoutConfig { + return arg.type !== undefined && arg.type == "fieldSet"; +} export type FormConfig = { type: "form"; @@ -32,7 +50,7 @@ export type FormConfig = { mode?: "edit" | "add" | "all"; fragmentName?: string; createMutation?: string; - fields: FormFieldConfig[]; + fields: (FormFieldConfig | FormLayoutConfig)[]; }; export type TabsConfig = { type: "tabs"; tabs: { name: string; content: GeneratorConfig }[] };