From c3e740b5525421bb443ac4c08094a5e1986d25fa Mon Sep 17 00:00:00 2001 From: chenos Date: Thu, 26 Sep 2024 17:12:16 +0800 Subject: [PATCH] feat: public forms (#5142) * feat: public forms plugin * refactor: public form * refactor: parseCollectionData * refactor: parseToken * refactor: handleEditPublicForm * refactor: parseACL * refactor: enable form * refactor: copy link * refactor: edable password * refactor: qr code * refactor: create public form * refactor: create public form * refactor: bulk destroy * fix: bug * refactor: support nester form * refactor: show message under control * refactor: support bulk destroy * refactor: support bulk destroy * refactor: adapt to mobile devices * refactor: settings * refactor: locale improve * refactor: parseAcl * refactor: parseAcl * fix: bug * fix: bug * fix: bug * fix: bug * refactor: dataSourceCollectionCascaderReadPretty * fix: bug * fix: bug * fix: bug * fix: collection template * fix: style improve * refactor: locale improve * refactor: parseToken * fix: bug * style: brand style improve * refactor: settings * fix: bug * fix: bug * refactor: locale improve * refactor: locale improve * refactor: locale improve * refactor: locale improve * refactor: package.json * refactor: create form schema * refactor: create form schema * fix: bug * fix: parseToken * fix: publicFormsSchema * fix: publicFormsSchema * fix: useSubmitActionProps * fix: useSubmitActionProps * fix: password * fix: password * refactor: custom request * refactor: variable for public form * style: style improve * fix: bug * style: style improve * style: style improve * refactor: filter * refactor: locale improve * refactor: locale improve * refactor: locale improve * fix: bug --------- Co-authored-by: katherinehhh --- packages/core/client/src/acl/ACLProvider.tsx | 10 +- .../interfaces/properties/operators.ts | 8 +- .../submit/createSubmitActionSettings.tsx | 10 + .../form/createFormActionInitializers.tsx | 5 + .../Select/selectComponentFieldSettings.tsx | 4 +- .../antd/block-item/index.tsx | 1 + .../collection-select/CollectionSelect.tsx | 54 +++- .../schema-component/common/utils/uitls.tsx | 6 +- .../src/schema-settings/SchemaSettings.tsx | 124 +++++---- .../VariableInput/hooks/useVariableOptions.ts | 9 +- .../src/variables/VariablesProvider.tsx | 3 +- packages/core/client/src/variables/types.ts | 1 + .../@nocobase/plugin-public-forms/.npmignore | 2 + .../@nocobase/plugin-public-forms/client.d.ts | 2 + .../@nocobase/plugin-public-forms/client.js | 1 + .../plugin-public-forms/package.json | 20 ++ .../@nocobase/plugin-public-forms/server.d.ts | 2 + .../@nocobase/plugin-public-forms/server.js | 1 + .../src/client/client.d.ts | 249 +++++++++++++++++ .../src/client/collections/index.ts | 10 + .../src/client/collections/publicForms.ts | 142 ++++++++++ .../client/components/AdminPublicFormList.tsx | 37 +++ .../client/components/AdminPublicFormPage.tsx | 213 +++++++++++++++ .../src/client/components/ConfigureLink.tsx | 19 ++ .../src/client/components/PublicFormPage.tsx | 225 ++++++++++++++++ .../components/UnEnabledFormPlaceholder.tsx | 23 ++ .../src/client/hooks/index.ts | 14 + .../src/client/hooks/useDeleteActionProps.ts | 41 +++ .../src/client/hooks/useEditFormProps.ts | 27 ++ .../hooks/usePublicSubmitActionProps.ts | 49 ++++ .../src/client/hooks/useSubmitActionProps.ts | 101 +++++++ .../plugin-public-forms/src/client/index.tsx | 66 +++++ .../plugin-public-forms/src/client/locale.ts | 30 +++ .../src/client/schemas/createActionSchema.ts | 94 +++++++ .../src/client/schemas/editActionSchema.ts | 96 +++++++ .../src/client/schemas/formSchemaCallback.ts | 47 ++++ .../src/client/schemas/index.ts | 10 + .../src/client/schemas/publicForms.tsx | 252 ++++++++++++++++++ .../src/client/settings/index.tsx | 40 +++ .../plugin-public-forms/src/index.ts | 12 + .../plugin-public-forms/src/locale/zh-CN.json | 14 + .../src/server/collections/publicForms.ts | 52 ++++ .../plugin-public-forms/src/server/hook.ts | 106 ++++++++ .../plugin-public-forms/src/server/index.ts | 10 + .../plugin-public-forms/src/server/plugin.ts | 199 ++++++++++++++ packages/presets/nocobase/package.json | 1 + 46 files changed, 2370 insertions(+), 72 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-public-forms/.npmignore create mode 100644 packages/plugins/@nocobase/plugin-public-forms/client.d.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/client.js create mode 100644 packages/plugins/@nocobase/plugin-public-forms/package.json create mode 100644 packages/plugins/@nocobase/plugin-public-forms/server.d.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/server.js create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/client.d.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/collections/index.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/collections/publicForms.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormList.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormPage.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/components/ConfigureLink.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/components/UnEnabledFormPlaceholder.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/index.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useDeleteActionProps.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useEditFormProps.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/usePublicSubmitActionProps.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useSubmitActionProps.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/index.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/locale.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/createActionSchema.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/editActionSchema.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/formSchemaCallback.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/index.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/publicForms.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/client/settings/index.tsx create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/index.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/locale/zh-CN.json create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/server/collections/publicForms.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/server/hook.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/server/index.ts create mode 100644 packages/plugins/@nocobase/plugin-public-forms/src/server/plugin.ts diff --git a/packages/core/client/src/acl/ACLProvider.tsx b/packages/core/client/src/acl/ACLProvider.tsx index 0a8f685ca..edba0767b 100644 --- a/packages/core/client/src/acl/ACLProvider.tsx +++ b/packages/core/client/src/acl/ACLProvider.tsx @@ -103,6 +103,13 @@ export const useACLContext = () => { export const ACLActionParamsContext = createContext({}); ACLActionParamsContext.displayName = 'ACLActionParamsContext'; +export const ACLCustomContext = createContext({}); +ACLCustomContext.displayName = 'ACLCustomContext'; + +const useACLCustomContext = () => { + return useContext(ACLCustomContext); +}; + export const useACLRolesCheck = () => { const ctx = useContext(ACLContext); const dataSourceName = useDataSourceKey(); @@ -218,9 +225,10 @@ export function useUIConfigurationPermissions(): { allowConfigUI: boolean } { export const ACLCollectionProvider = (props) => { const { allowAll, parseAction } = useACLRoleContext(); + const { allowAll: customAllowAll } = useACLCustomContext(); const app = useApp(); const schema = useFieldSchema(); - if (allowAll || app.disableAcl) { + if (allowAll || app.disableAcl || customAllowAll) { return props.children; } let actionPath = schema?.['x-acl-action'] || props.actionPath; diff --git a/packages/core/client/src/collection-manager/interfaces/properties/operators.ts b/packages/core/client/src/collection-manager/interfaces/properties/operators.ts index 5e3530d84..1e031153c 100644 --- a/packages/core/client/src/collection-manager/interfaces/properties/operators.ts +++ b/packages/core/client/src/collection-manager/interfaces/properties/operators.ts @@ -157,18 +157,18 @@ export const collection = [ label: '{{t("is")}}', value: '$eq', selected: true, - schema: { 'x-component': 'CollectionSelect' }, + schema: { 'x-component': 'DataSourceCollectionCascader' }, }, { label: '{{t("is not")}}', value: '$ne', - schema: { 'x-component': 'CollectionSelect' }, + schema: { 'x-component': 'DataSourceCollectionCascader' }, }, { label: '{{t("is any of")}}', value: '$in', schema: { - 'x-component': 'CollectionSelect', + 'x-component': 'DataSourceCollectionCascader', 'x-component-props': { mode: 'tags' }, }, }, @@ -176,7 +176,7 @@ export const collection = [ label: '{{t("is none of")}}', value: '$notIn', schema: { - 'x-component': 'CollectionSelect', + 'x-component': 'DataSourceCollectionCascader', 'x-component-props': { mode: 'tags' }, }, }, diff --git a/packages/core/client/src/modules/actions/submit/createSubmitActionSettings.tsx b/packages/core/client/src/modules/actions/submit/createSubmitActionSettings.tsx index a7756c36f..a6fe8cae2 100644 --- a/packages/core/client/src/modules/actions/submit/createSubmitActionSettings.tsx +++ b/packages/core/client/src/modules/actions/submit/createSubmitActionSettings.tsx @@ -28,6 +28,8 @@ import { } from '../../../schema-component/antd/action/Action.Designer'; import { useCollectionState } from '../../../schema-settings/DataTemplates/hooks/useCollectionState'; import { SchemaSettingsModalItem } from '../../../schema-settings/SchemaSettings'; +import { useParentPopupRecord } from '../../variable/variablesProvider/VariablePopupRecordProvider'; +import { useDataBlockProps } from '../../../data-source'; const Tree = connect( AntdTree, @@ -163,6 +165,10 @@ export const createSubmitActionSettings = new SchemaSettings({ { name: 'saveMode', Component: SaveMode, + useVisible() { + const { type } = useDataBlockProps(); + return type !== 'publicForm'; + }, }, { name: 'assignFieldValues', @@ -188,6 +194,10 @@ export const createSubmitActionSettings = new SchemaSettings({ isPopupAction: false, }; }, + useVisible() { + const parentRecord = useParentPopupRecord(); + return !!parentRecord; + }, }, { name: 'remove', diff --git a/packages/core/client/src/modules/blocks/data-blocks/form/createFormActionInitializers.tsx b/packages/core/client/src/modules/blocks/data-blocks/form/createFormActionInitializers.tsx index 1cc415fa8..ee53b3cc0 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/form/createFormActionInitializers.tsx +++ b/packages/core/client/src/modules/blocks/data-blocks/form/createFormActionInitializers.tsx @@ -8,6 +8,7 @@ */ import { CompatibleSchemaInitializer } from '../../../../application/schema-initializer/CompatibleSchemaInitializer'; +import { useDataBlockProps } from '../../../../data-source'; const commonOptions = { title: '{{t("Configure actions")}}', @@ -25,6 +26,10 @@ const commonOptions = { name: 'customRequest', title: '{{t("Custom request")}}', Component: 'CustomRequestInitializer', + useVisible() { + const { type } = useDataBlockProps(); + return type !== 'publicForm'; + }, }, ], }; diff --git a/packages/core/client/src/modules/fields/component/Select/selectComponentFieldSettings.tsx b/packages/core/client/src/modules/fields/component/Select/selectComponentFieldSettings.tsx index 57032353b..49bb337f7 100644 --- a/packages/core/client/src/modules/fields/component/Select/selectComponentFieldSettings.tsx +++ b/packages/core/client/src/modules/fields/component/Select/selectComponentFieldSettings.tsx @@ -34,6 +34,7 @@ import { SchemaSettingsSortingRule } from '../../../../schema-settings/SchemaSet import { useIsShowMultipleSwitch } from '../../../../schema-settings/hooks/useIsShowMultipleSwitch'; import { useLocalVariables, useVariables } from '../../../../variables'; import { useOpenModeContext } from '../../../popup/OpenModeProvider'; +import { useDataBlockProps } from '../../../../data-source'; const enableLink = { name: 'enableLink', @@ -358,7 +359,8 @@ export const selectComponentFieldSettings = new SchemaSettings({ const isAssociationField = useIsAssociationField(); const readPretty = useIsFieldReadPretty(); const { fieldSchema } = useColumnSchema(); - return isAssociationField && !fieldSchema && !readPretty; + const { type } = useDataBlockProps(); + return isAssociationField && !fieldSchema && !readPretty && type !== 'publicForm'; }, }, { diff --git a/packages/core/client/src/schema-component/antd/block-item/index.tsx b/packages/core/client/src/schema-component/antd/block-item/index.tsx index de8481334..e3d0ccf25 100644 --- a/packages/core/client/src/schema-component/antd/block-item/index.tsx +++ b/packages/core/client/src/schema-component/antd/block-item/index.tsx @@ -9,3 +9,4 @@ export * from './BlockItem'; export * from './TestDesigner'; +export * from './BlockItemCard'; diff --git a/packages/core/client/src/schema-component/antd/collection-select/CollectionSelect.tsx b/packages/core/client/src/schema-component/antd/collection-select/CollectionSelect.tsx index e252773b0..79d40c226 100644 --- a/packages/core/client/src/schema-component/antd/collection-select/CollectionSelect.tsx +++ b/packages/core/client/src/schema-component/antd/collection-select/CollectionSelect.tsx @@ -11,6 +11,7 @@ import { connect, mapReadPretty, observer, useField } from '@formily/react'; import { Cascader, Select, SelectProps, Tag } from 'antd'; import React, { useCallback, useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from '@emotion/css'; import { useSelfAndChildrenCollections } from '../../../collection-manager/action-hooks'; import { useCollection_deprecated, useCollectionManager_deprecated } from '../../../collection-manager/hooks'; import { useCompile } from '../../hooks'; @@ -148,6 +149,57 @@ export const DataSourceSelect = connect((props: DataSourceSelectProps) => { ); }); +const DataSourceCollectionCascaderReadPretty = observer((props: any) => { + const dataSourceManager = useDataSourceManager(); + const compile = useCompile(); + const { value, onChange, dataSourceFilter, collectionFilter, ...others } = props; + const [dataSourceName, collectionName] = parseCollectionName(value); + const path = [dataSourceName, collectionName].filter(Boolean); + const dataSources = dataSourceManager.getDataSources(); + + const options = useMemo(() => { + return (dataSourceFilter ? dataSources.filter(dataSourceFilter) : dataSources).map((dataSource) => { + return { + label: compile(dataSource.displayName), + value: dataSource.key, + children: dataSource.collectionManager.collectionInstancesArr + .filter(collectionFilter ?? ((collection) => !collection.hidden)) + .map((collection) => { + return { + label: compile(collection.title), + value: collection.name, + }; + }), + }; + }); + }, [dataSources, dataSourceFilter, collectionFilter]); + const getDisplayValue = (value: string[], options: any[]): string[] => { + const displayValues: string[] = []; + + let currentOptions = options; + + value.forEach((val) => { + const option = currentOptions.find((item) => item.value === val); + if (option) { + displayValues.push(option.label as string); // 假设label为string类型 + if (option.children) { + currentOptions = option.children; + } else { + currentOptions = []; + } + } + }); + + return displayValues; + }; + const displayValues = getDisplayValue(path, options); + return ( +
+ {displayValues.join(' / ')} +
+ ); +}); + export const DataSourceCollectionCascader = connect((props) => { const dataSourceManager = useDataSourceManager(); const compile = useCompile(); @@ -183,4 +235,4 @@ export const DataSourceCollectionCascader = connect((props) => { [onChange], ); return ; -}); +}, mapReadPretty(DataSourceCollectionCascaderReadPretty)); diff --git a/packages/core/client/src/schema-component/common/utils/uitls.tsx b/packages/core/client/src/schema-component/common/utils/uitls.tsx index 7d12ca7f0..a0e174a3b 100644 --- a/packages/core/client/src/schema-component/common/utils/uitls.tsx +++ b/packages/core/client/src/schema-component/common/utils/uitls.tsx @@ -193,12 +193,12 @@ export async function getRenderContent(templateEngine, content, variables, local const renderedContent = Handlebars.compile(content); // 处理渲染后的内容 const data = getVariablesData(localVariables); - const { $nDate } = variables.ctxRef.current; + const { $nDate } = variables?.ctxRef?.current || {}; const variableDate = {}; - Object.keys($nDate).map((v) => { + Object.keys($nDate || {}).map((v) => { variableDate[v] = $nDate[v](); }); - const html = renderedContent({ ...variables.ctxRef.current, ...data, $nDate: variableDate }); + const html = renderedContent({ ...variables?.ctxRef?.current, ...data, $nDate: variableDate }); return await defaultParse(html); } catch (error) { console.log(error); diff --git a/packages/core/client/src/schema-settings/SchemaSettings.tsx b/packages/core/client/src/schema-settings/SchemaSettings.tsx index 117d2eda1..81334476f 100644 --- a/packages/core/client/src/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/schema-settings/SchemaSettings.tsx @@ -103,7 +103,7 @@ import { ChildDynamicComponent } from './EnableChildCollections/DynamicComponent import { FormLinkageRules } from './LinkageRules'; import { useLinkageCollectionFieldOptions } from './LinkageRules/action-hooks'; import { LinkageRuleCategory, LinkageRuleDataKeyMap } from './LinkageRules/type'; - +import { VariablesContext } from '../'; export interface SchemaSettingsProps { title?: any; dn?: Designable; @@ -778,6 +778,7 @@ export const SchemaSettingsModalItem: FC = (props) const blockOptions = useBlockContext(); const { getOperators } = useOperators(); const locationSearch = useLocationSearch(); + const variableOptions = useContext(VariablesContext); // 解决变量`当前对象`值在弹窗中丢失的问题 const { formValue: subFormValue, collection: subFormCollection } = useSubFormValue(); @@ -789,6 +790,7 @@ export const SchemaSettingsModalItem: FC = (props) if (hidden) { return null; } + return ( = (props) () => { return ( - - - - - - - - - - - - 576px - @media (min-width: 576px) { - min-width: 520px; - } + + + + + + + + + + + + + 576px + @media (min-width: 576px) { + min-width: 520px; + } - // screen <= 576px - @media (max-width: 576px) { - min-width: 320px; - } - `} - > - - - - - - - - - - - - - - - - - - - + // screen <= 576px + @media (max-width: 576px) { + min-width: 320px; + } + `} + > + + + + + + + + + + + + + + + + + + + + ); }, diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts index 485b721d9..a441cec2e 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts @@ -9,7 +9,7 @@ import { Form } from '@formily/core'; import { ISchema, Schema } from '@formily/react'; -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { CollectionFieldOptions_deprecated } from '../../../collection-manager'; import { useAPITokenVariable } from './useAPITokenVariable'; import { useDatetimeVariable } from './useDateVariable'; @@ -22,6 +22,7 @@ import { useCurrentRecordVariable } from './useRecordVariable'; import { useCurrentRoleVariable } from './useRoleVariable'; import { useURLSearchParamsVariable } from './useURLSearchParamsVariable'; import { useCurrentUserVariable } from './useUserVariable'; +import { VariablesContext } from '../../../'; interface Props { /** @@ -58,6 +59,7 @@ export const useVariableOptions = ({ targetFieldSchema, record, }: Props) => { + const { filterVariables = () => true } = useContext(VariablesContext) || {}; const blockParentCollectionName = record?.__parent?.__collectionName; const { currentUserSettings } = useCurrentUserVariable({ maxDepth: 3, @@ -113,7 +115,6 @@ export const useVariableOptions = ({ targetFieldSchema, }); const { urlSearchParamsSettings, shouldDisplay: shouldDisplayURLSearchParams } = useURLSearchParamsVariable(); - return useMemo(() => { return [ currentUserSettings, @@ -127,7 +128,9 @@ export const useVariableOptions = ({ shouldDisplayPopupRecord && popupRecordSettings, shouldDisplayParentPopupRecord && parentPopupRecordSettings, shouldDisplayURLSearchParams && urlSearchParamsSettings, - ].filter(Boolean); + ] + .filter(Boolean) + .filter(filterVariables); }, [ currentUserSettings, currentRoleSettings, diff --git a/packages/core/client/src/variables/VariablesProvider.tsx b/packages/core/client/src/variables/VariablesProvider.tsx index a40c77b56..05cd96546 100644 --- a/packages/core/client/src/variables/VariablesProvider.tsx +++ b/packages/core/client/src/variables/VariablesProvider.tsx @@ -49,7 +49,7 @@ const getFieldPath = (variablePath: string, variablesStore: Record { +const VariablesProvider = ({ children, filterVariables }: any) => { const ctxRef = useRef>({}); const api = useAPIClient(); const { getCollectionJoinField, getCollection } = useCollectionManager_deprecated(); @@ -329,6 +329,7 @@ const VariablesProvider = ({ children }) => { getVariable, getCollectionField, removeVariable, + filterVariables, }) as VariablesContextType, [getCollectionField, getVariable, parseVariable, registerVariable, removeVariable, setCtx], ); diff --git a/packages/core/client/src/variables/types.ts b/packages/core/client/src/variables/types.ts index eb33bcef6..2f3eeeb4e 100644 --- a/packages/core/client/src/variables/types.ts +++ b/packages/core/client/src/variables/types.ts @@ -87,6 +87,7 @@ export interface VariablesContextType { localVariables?: VariableOption | VariableOption[], ) => Promise; removeVariable: (variableName: string) => void; + filterVariables?: (params) => boolean; //自定义过滤变量 } export interface VariableOption { diff --git a/packages/plugins/@nocobase/plugin-public-forms/.npmignore b/packages/plugins/@nocobase/plugin-public-forms/.npmignore new file mode 100644 index 000000000..65f5e8779 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-public-forms/client.d.ts b/packages/plugins/@nocobase/plugin-public-forms/client.d.ts new file mode 100644 index 000000000..6c459cbac --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-public-forms/client.js b/packages/plugins/@nocobase/plugin-public-forms/client.js new file mode 100644 index 000000000..b6e3be70e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-public-forms/package.json b/packages/plugins/@nocobase/plugin-public-forms/package.json new file mode 100644 index 000000000..6ee4c7a5c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/package.json @@ -0,0 +1,20 @@ +{ + "name": "@nocobase/plugin-public-forms", + "version": "1.4.0-alpha", + "main": "dist/server/index.js", + "dependencies": {}, + "displayName": "Public forms", + "displayName.zh-CN": "公开表单", + "description": "Provides a public form that allows users to submit information without requiring registration or login.", + "description.zh-CN": "提供了一种无需用户注册或登录即可提交信息的表单", + "license": "AGPL-3.0", + "homepage": "https://docs.nocobase.com/handbook/public-form", + "homepage.zh-CN": "https://docs-cn.nocobase.com/public-form", + "peerDependencies": { + "@nocobase/client": "1.x", + "@nocobase/plugin-client": "1.x", + "@nocobase/plugin-ui-schema-storage": "1.x", + "@nocobase/server": "1.x", + "@nocobase/test": "1.x" + } +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/server.d.ts b/packages/plugins/@nocobase/plugin-public-forms/server.d.ts new file mode 100644 index 000000000..c41081ddc --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-public-forms/server.js b/packages/plugins/@nocobase/plugin-public-forms/server.js new file mode 100644 index 000000000..972842039 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/client.d.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/client.d.ts new file mode 100644 index 000000000..4e96f83fa --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/client.d.ts @@ -0,0 +1,249 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +// CSS modules +type CSSModuleClasses = { readonly [key: string]: string }; + +declare module '*.module.css' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.scss' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.sass' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.less' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.styl' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.stylus' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.pcss' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.sss' { + const classes: CSSModuleClasses; + export default classes; +} + +// CSS +declare module '*.css' { } +declare module '*.scss' { } +declare module '*.sass' { } +declare module '*.less' { } +declare module '*.styl' { } +declare module '*.stylus' { } +declare module '*.pcss' { } +declare module '*.sss' { } + +// Built-in asset types +// see `src/node/constants.ts` + +// images +declare module '*.apng' { + const src: string; + export default src; +} +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.jpg' { + const src: string; + export default src; +} +declare module '*.jpeg' { + const src: string; + export default src; +} +declare module '*.jfif' { + const src: string; + export default src; +} +declare module '*.pjpeg' { + const src: string; + export default src; +} +declare module '*.pjp' { + const src: string; + export default src; +} +declare module '*.gif' { + const src: string; + export default src; +} +declare module '*.svg' { + const src: string; + export default src; +} +declare module '*.ico' { + const src: string; + export default src; +} +declare module '*.webp' { + const src: string; + export default src; +} +declare module '*.avif' { + const src: string; + export default src; +} + +// media +declare module '*.mp4' { + const src: string; + export default src; +} +declare module '*.webm' { + const src: string; + export default src; +} +declare module '*.ogg' { + const src: string; + export default src; +} +declare module '*.mp3' { + const src: string; + export default src; +} +declare module '*.wav' { + const src: string; + export default src; +} +declare module '*.flac' { + const src: string; + export default src; +} +declare module '*.aac' { + const src: string; + export default src; +} +declare module '*.opus' { + const src: string; + export default src; +} +declare module '*.mov' { + const src: string; + export default src; +} +declare module '*.m4a' { + const src: string; + export default src; +} +declare module '*.vtt' { + const src: string; + export default src; +} + +// fonts +declare module '*.woff' { + const src: string; + export default src; +} +declare module '*.woff2' { + const src: string; + export default src; +} +declare module '*.eot' { + const src: string; + export default src; +} +declare module '*.ttf' { + const src: string; + export default src; +} +declare module '*.otf' { + const src: string; + export default src; +} + +// other +declare module '*.webmanifest' { + const src: string; + export default src; +} +declare module '*.pdf' { + const src: string; + export default src; +} +declare module '*.txt' { + const src: string; + export default src; +} + +// wasm?init +declare module '*.wasm?init' { + const initWasm: (options?: WebAssembly.Imports) => Promise; + export default initWasm; +} + +// web worker +declare module '*?worker' { + const workerConstructor: { + new(options?: { name?: string }): Worker; + }; + export default workerConstructor; +} + +declare module '*?worker&inline' { + const workerConstructor: { + new(options?: { name?: string }): Worker; + }; + export default workerConstructor; +} + +declare module '*?worker&url' { + const src: string; + export default src; +} + +declare module '*?sharedworker' { + const sharedWorkerConstructor: { + new(options?: { name?: string }): SharedWorker; + }; + export default sharedWorkerConstructor; +} + +declare module '*?sharedworker&inline' { + const sharedWorkerConstructor: { + new(options?: { name?: string }): SharedWorker; + }; + export default sharedWorkerConstructor; +} + +declare module '*?sharedworker&url' { + const src: string; + export default src; +} + +declare module '*?raw' { + const src: string; + export default src; +} + +declare module '*?url' { + const src: string; + export default src; +} + +declare module '*?inline' { + const src: string; + export default src; +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/collections/index.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/collections/index.ts new file mode 100644 index 000000000..b33a567fa --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/collections/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './publicForms'; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/collections/publicForms.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/collections/publicForms.ts new file mode 100644 index 000000000..93e0301fc --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/collections/publicForms.ts @@ -0,0 +1,142 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ +import { NAMESPACE } from '../locale'; + +export const publicFormsCollection = { + name: 'publicForms', + filterTargetKey: 'key', + fields: [ + { + type: 'string', + name: 'title', + interface: 'input', + uiSchema: { + type: 'string', + title: "{{t('Title')}}", + required: true, + 'x-component': 'Input', + }, + }, + { + type: 'text', + name: 'description', + interface: 'textarea', + uiSchema: { + type: 'string', + title: "{{t('Description')}}", + 'x-component': 'Input.TextArea', + }, + }, + { + type: 'string', + name: 'type', + interface: 'radioGroup', + uiSchema: { + type: 'string', + title: `{{t("Type",{ns:"public-forms"})}}`, + 'x-component': 'Radio.Group', + enum: '{{ formTypes }}', + }, + }, + { + type: 'string', + name: 'collection', + interface: 'collection', + uiSchema: { + type: 'string', + title: "{{t('Collection')}}", + required: true, + 'x-component': 'DataSourceCollectionCascader', + }, + }, + { + type: 'boolean', + name: 'enabledPassword', + interface: 'checkbox', + uiSchema: { + type: 'string', + title: `{{t("Enable password",{ns:"${NAMESPACE}"})}}`, + 'x-component': 'Checkbox', + default: true, + }, + }, + { + type: 'password', + name: 'password', + interface: 'password', + uiSchema: { + type: 'string', + title: "{{t('Password')}}", + 'x-component': 'Password', + 'x-component-props': { + autocomplete: 'new-password', + }, + }, + }, + { + type: 'boolean', + name: 'enabled', + interface: 'checkbox', + uiSchema: { + type: 'string', + title: `{{t("Enable form",{ns:"${NAMESPACE}"})}}`, + 'x-component': 'Checkbox', + default: true, + }, + }, + // { + // type: 'date', + // name: 'createdAt', + // interface: 'createdAt', + // uiSchema: { + // type: 'string', + // title: "{{t('CreatedAt')}}", + // 'x-component': 'DatePicker', + // 'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: true, timeFormat: 'HH:mm:ss' }, + // }, + // }, + // { + // type: 'date', + // name: 'createdBy', + // interface: 'createdBy', + // target: 'users', + // foreignKey: 'createdById', + // uiSchema: { + // title: '{{t("Created by")}}', + // 'x-component': 'RecordPicker', + // 'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } }, + // 'x-read-pretty': true, + // }, + // }, + // { + // type: 'object', + // name: 'updatedAt', + // interface: 'updatedAt', + // uiSchema: { + // type: 'string', + // title: "{{t('updatedAt')}}", + // 'x-component': 'DatePicker', + // 'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: true, timeFormat: 'HH:mm:ss' }, + // }, + // }, + // { + // type: 'object', + // name: 'updatedBy', + // interface: 'updatedBy', + // target: 'users', + // foreignKey: 'updatedById', + // uiSchema: { + // title: '{{t("Last updated by")}}', + // 'x-component': 'RecordPicker', + // 'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } }, + // 'x-read-pretty': true, + // }, + // }, + ], +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormList.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormList.tsx new file mode 100644 index 000000000..fe46c8e60 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormList.tsx @@ -0,0 +1,37 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { + ExtendCollectionsProvider, + SchemaComponent, + usePlugin, + SchemaComponentContext, + useSchemaComponentContext, +} from '@nocobase/client'; +import React, { useMemo } from 'react'; +import PluginPublicFormsClient from '..'; +import { publicFormsCollection } from '../collections'; +import { useDeleteActionProps, useEditFormProps, useSubmitActionProps } from '../hooks'; +import { publicFormsSchema } from '../schemas'; + +export const AdminPublicFormList = () => { + const plugin = usePlugin(PluginPublicFormsClient); + const scCtx = useSchemaComponentContext(); + const formTypes = useMemo(() => plugin.getFormTypeOptions(), [plugin]); + return ( + + + + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormPage.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormPage.tsx new file mode 100644 index 000000000..e9b255e32 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/AdminPublicFormPage.tsx @@ -0,0 +1,213 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { EyeOutlined, SettingOutlined } from '@ant-design/icons'; +import { + PoweredBy, + RemoteSchemaComponent, + useRequest, + useAPIClient, + SchemaComponentOptions, + FormDialog, + SchemaComponent, + useGlobalTheme, + FormItem, + Checkbox, + VariablesProvider, +} from '@nocobase/client'; +import { Breadcrumb, Button, Dropdown, Space, Spin, Switch, Input, message, Popover, QRCode } from 'antd'; +import React, { useState } from 'react'; +import { useParams } from 'react-router'; +import { Link } from 'react-router-dom'; +import { FormLayout } from '@formily/antd-v5'; +import { usePublicSubmitActionProps } from '../hooks'; +import { usePublicFormTranslation, NAMESPACE } from '../locale'; + +const PublicFormQRCode = () => { + const params = useParams(); + const [open, setOpen] = useState(false); + const baseURL = window.location.origin; + const { t } = usePublicFormTranslation(); + const link = `${baseURL}/public-forms/${params.name}`; + const handleQRCodeOpen = (newOpen: boolean) => { + setOpen(newOpen); + }; + return ( + : ' '} + > + {t('QR code', { ns: NAMESPACE })} + + ); +}; +export function AdminPublicFormPage() { + const params = useParams(); + const { t } = usePublicFormTranslation(); + const { theme } = useGlobalTheme(); + const apiClient = useAPIClient(); + const { data, loading, refresh } = useRequest({ + url: `publicForms:get/${params.name}`, + }); + const { enabled, title, ...others } = data?.data || {}; + if (loading) { + return ; + } + const handleEditPublicForm = async (values) => { + await apiClient.resource('publicForms').update({ + filterByTk: params.name, + values: { ...values }, + }); + await refresh(); + }; + + const handleSetPassword = async () => { + const values = await FormDialog( + t('Password') as any, + () => { + return ( + + + + + + ); + }, + theme, + ).open({ + initialValues: { ...others }, + }); + const { enabledPassword, password } = values; + await handleEditPublicForm({ enabledPassword, password }); + }; + + const handleCopyLink = () => { + const baseURL = window.location.origin; + const link = `${baseURL}/public-forms/${params.name}`; + navigator.clipboard.writeText(link); + message.success(t('Link copied successfully')); + }; + + return ( +
+
+ {t('Public forms', { ns: NAMESPACE })}, + }, + { + title: title, + }, + ]} + /> + + + + + + {t('Enable form', { ns: NAMESPACE })} + handleEditPublicForm({ enabled: checked })} + /> + + ), + }, + { + key: 'password', + label: {t('Set password')}, + }, + { + key: 'divider1', + type: 'divider', + }, + { + key: 'copyLink', + label: {t('Copy link')}, + }, + { + key: 'qrcode', + label: , + }, + ], + }} + > + + + +
+
+ { + return !['$user', '$nRole', '$nToken', '$nURLSearchParams'].includes(v.key); + }} + > + props.children }} + /> + + +
+
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/ConfigureLink.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/ConfigureLink.tsx new file mode 100644 index 000000000..fe90f4d43 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/ConfigureLink.tsx @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useFilterByTk } from '@nocobase/client'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useT } from '../locale'; +export function ConfigureLink() { + const value = useFilterByTk(); + const t = useT(); + + return {t('Configure')}; +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx new file mode 100644 index 000000000..a46e79047 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx @@ -0,0 +1,225 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { + APIClient, + APIClientProvider, + CollectionManager, + DataSource, + DataSourceApplicationProvider, + DataSourceManager, + PoweredBy, + SchemaComponent, + SchemaComponentContext, + useAPIClient, + useApp, + useRequest, + ACLCustomContext, + VariablesProvider, +} from '@nocobase/client'; +import { css } from '@emotion/css'; +import { isDesktop } from 'react-device-detect'; +import { useField } from '@formily/react'; +import { Input, Modal, Spin } from 'antd'; +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router'; +import { usePublicSubmitActionProps } from '../hooks'; +import { UnEnabledFormPlaceholder } from './UnEnabledFormPlaceholder'; +class PublicDataSource extends DataSource { + async getDataSource() { + return {}; + } +} + +function PublicPublicFormProvider(props) { + const { dataSource } = props; + const app = useApp(); + const [dataSourceManager, collectionManager] = useMemo(() => { + const dataSourceManager = new DataSourceManager({}, app); + const dataSourceInstance = dataSourceManager.addDataSource(PublicDataSource, dataSource); + const collectionManager = new CollectionManager([], dataSourceInstance); + return [dataSourceManager, collectionManager]; + }, [app, dataSource]); + return ( +
+ + {props.children} + +
+ ); +} + +function PublicAPIClientProvider({ children }) { + const app = useApp(); + const apiClient = useMemo(() => { + const apiClient = new APIClient(app.getOptions().apiClient as any); + apiClient.app = app; + apiClient.axios.interceptors.request.use((config) => { + if (config.headers) { + config.headers['X-Form-Token'] = localStorage.getItem('NOCOBASE_FORM_TOKEN') || ''; + } + return config; + }); + return apiClient; + }, [app]); + return {children}; +} + +export const PublicFormMessageContext = createContext({}); +export const PageBackgroundColor = '#f5f5f5'; + +const PublicFormMessageProvider = ({ children }) => { + const [showMessage, setShowMessage] = useState(false); + const field = useField(); + + const toggleFieldVisibility = (fieldName, visible) => { + field.form.query(fieldName).take((f) => { + if (f) { + f.visible = visible; + f.hidden = !visible; + f.decoratorProps.title = null; + } + }); + }; + + useEffect(() => { + toggleFieldVisibility('success', showMessage); + toggleFieldVisibility('form', !showMessage); + }, [showMessage]); + + return ( + + {children} + + ); +}; + +function InternalPublicForm() { + const params = useParams(); + const apiClient = useAPIClient(); + const { error, data, loading, run } = useRequest( + { + url: `publicForms:getMeta/${params.name}`, + }, + { + onSuccess(data) { + localStorage.setItem('NOCOBASE_FORM_TOKEN', data?.data?.token); + apiClient.axios.interceptors.request.use((config) => { + if (config.headers) { + config.headers['X-Form-Token'] = data?.data?.token || ''; + } + return config; + }); + }, + }, + ); + const [pwd, setPwd] = useState(''); + const ctx = useContext(SchemaComponentContext); + // 设置的移动端 meta + React.useEffect(() => { + if (!isDesktop) { + let viewportMeta = document.querySelector('meta[name="viewport"]'); + if (!viewportMeta) { + viewportMeta = document.createElement('meta'); + viewportMeta.setAttribute('name', 'viewport'); + document.head.appendChild(viewportMeta); + } + viewportMeta.setAttribute('content', 'width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no'); + + document.body.style.backgroundColor = PageBackgroundColor; + document.body.style.overflow = 'hidden'; + + // 触发视图重绘 + const fakeBody = document.createElement('div'); + document.body.appendChild(fakeBody); + document.body.removeChild(fakeBody); + } + }, []); + if (error || data?.data?.passwordRequired) { + return ( +
+ { + run({ + password: pwd, + }); + }} + > + { + setPwd(e.target.value); + }} + /> + +
+ ); + } + + if (loading) { + return ; + } + if (!data?.data) { + return ; + } + return ( + + +
+
+ + + + + + + +
+ +
+
+
+
+
+ ); +} + +export function PublicFormPage() { + return ; +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/UnEnabledFormPlaceholder.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/UnEnabledFormPlaceholder.tsx new file mode 100644 index 000000000..3c3ae7195 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/UnEnabledFormPlaceholder.tsx @@ -0,0 +1,23 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { BlockItemCard } from '@nocobase/client'; +import { Result } from 'antd'; +import { usePublicFormTranslation, NAMESPACE } from '../locale'; + +export const UnEnabledFormPlaceholder = () => { + const { t } = usePublicFormTranslation(); + + return ( + + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/index.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/index.ts new file mode 100644 index 000000000..da5003e03 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/index.ts @@ -0,0 +1,14 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './useDeleteActionProps'; +export * from './useEditFormProps'; +export * from './usePublicSubmitActionProps'; +export * from './useSubmitActionProps'; +// diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useDeleteActionProps.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useDeleteActionProps.ts new file mode 100644 index 000000000..a2f70e456 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useDeleteActionProps.ts @@ -0,0 +1,41 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { + ActionProps, + useCollection, + useCollectionRecordData, + useBlockRequestContext, + useDataBlockResource, +} from '@nocobase/client'; +import { App as AntdApp } from 'antd'; + +export function useDeleteActionProps(): ActionProps { + const { message } = AntdApp.useApp(); + const record = useCollectionRecordData(); + const resource = useDataBlockResource(); + const { service } = useBlockRequestContext(); + const collection = useCollection(); + return { + confirm: { + title: 'Delete', + content: 'Are you sure you want to delete it?', + }, + async onClick() { + if (!collection) { + throw new Error('collection does not exist'); + } + await resource.destroy({ + filterByTk: record[collection.filterTargetKey], + }); + await service.refresh(); + message.success('Deleted!'); + }, + }; +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useEditFormProps.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useEditFormProps.ts new file mode 100644 index 000000000..bb114951f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useEditFormProps.ts @@ -0,0 +1,27 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createForm } from '@formily/core'; +import { useCollectionRecordData } from '@nocobase/client'; +import { useMemo } from 'react'; + +export const useEditFormProps = () => { + const recordData = useCollectionRecordData(); + const form = useMemo( + () => + createForm({ + initialValues: recordData, + }), + [recordData], + ); + + return { + form, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/usePublicSubmitActionProps.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/usePublicSubmitActionProps.ts new file mode 100644 index 000000000..aef82ca29 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/usePublicSubmitActionProps.ts @@ -0,0 +1,49 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ +import { useContext } from 'react'; +import { useForm, useFieldSchema, useField } from '@formily/react'; +import { useDataBlockResource, useCollectValuesToSubmit, useFormBlockContext } from '@nocobase/client'; +import { PublicFormMessageContext } from '../components/PublicFormPage'; + +export const usePublicSubmitActionProps = () => { + const form = useForm(); + const resource = useDataBlockResource(); + const actionField = useField(); + const collectValues = useCollectValuesToSubmit(); + const actionSchema = useFieldSchema(); + const { updateAssociationValues } = useFormBlockContext(); + const { setShowMessage } = useContext(PublicFormMessageContext); + return { + type: 'primary', + async onClick() { + const { skipValidator, triggerWorkflows } = actionSchema?.['x-action-settings'] ?? {}; + if (!skipValidator) { + await form.submit(); + } + const values = await collectValues(); + actionField.data = actionField.data || {}; + actionField.data.loading = true; + try { + await form.submit(); + await resource.publicSubmit({ + values, + triggerWorkflows: triggerWorkflows?.length + ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',') + : undefined, + updateAssociationValues, + }); + await form.reset(); + actionField.data.loading = false; + setShowMessage(true); + } catch (error) { + actionField.data.loading = false; + } + }, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useSubmitActionProps.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useSubmitActionProps.ts new file mode 100644 index 000000000..c370adbe5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/hooks/useSubmitActionProps.ts @@ -0,0 +1,101 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useForm } from '@formily/react'; +import { uid } from '@formily/shared'; +import { + useActionContext, + useAPIClient, + useCollection, + useDataBlockRequest, + useDataBlockResource, + usePlugin, + useBlockRequestContext, +} from '@nocobase/client'; +import { App as AntdApp } from 'antd'; +import PluginPublicFormsClient from '..'; + +const initialSchema = (values, formSchema) => { + return { + type: 'void', + name: uid(), + 'x-decorator': 'PublicFormMessageProvider', + properties: { + form: formSchema, + success: { + type: 'void', + 'x-editable': false, + 'x-toolbar-props': { + draggable: false, + }, + 'x-settings': 'blockSettings:markdown', + 'x-component': 'Markdown.Void', + 'x-decorator': 'CardItem', + 'x-component-props': { + content: 'Submitted Successfully', + }, + 'x-decorator-props': { + name: 'markdown', + engine: 'handlebars', + title: '{{ t("After successful submission",{ns:"public-forms"})}}', + }, + }, + }, + }; +}; + +export const useSubmitActionProps = () => { + const { setVisible } = useActionContext(); + const { message } = AntdApp.useApp(); + const form = useForm(); + const resource = useDataBlockResource(); + const collection = useCollection(); + const api = useAPIClient(); + const plugin = usePlugin(PluginPublicFormsClient); + const { service } = useBlockRequestContext(); + + return { + type: 'primary', + async onClick() { + await form.submit(); + const values = form.values; + if (values[collection.filterTargetKey]) { + await resource.update({ + values, + filterByTk: values[collection.filterTargetKey], + }); + } else { + const key = uid(); + const uiSchemaCallback = plugin.getFormSchemaByType(values.type); + const keys = values.collection.split(':'); + const collection = keys.pop(); + const dataSource = keys.pop() || 'main'; + const schema = initialSchema( + values, + uiSchemaCallback({ + collection, + dataSource, + }), + ); + schema['x-uid'] = key; + await resource.create({ + values: { + ...values, + key, + }, + }); + await api.resource('uiSchemas').insert({ values: schema }); + } + form.reset(); + await service.refresh(); + message.success('Saved successfully!'); + setVisible(false); + }, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/index.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/index.tsx new file mode 100644 index 000000000..e5c58d55d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/index.tsx @@ -0,0 +1,66 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { ISchema, Plugin } from '@nocobase/client'; +import { AdminPublicFormList } from './components/AdminPublicFormList'; +import { AdminPublicFormPage } from './components/AdminPublicFormPage'; +import { PublicFormPage } from './components/PublicFormPage'; +import { formSchemaCallback } from './schemas/formSchemaCallback'; +import { publicFormBlockSettings } from './settings'; +import { NAMESPACE } from './locale'; +export class PluginPublicFormsClient extends Plugin { + protected formTypes = new Map(); + + registerFormType(type: string, options: { label: string; uiSchema: (options: any) => ISchema }) { + this.formTypes.set(type, options); + } + + getFormSchemaByType(type = 'form') { + if (this.formTypes.get(type)) { + return this.formTypes.get(type).uiSchema; + } + return () => { + return null; + }; + } + + getFormTypeOptions() { + const options = []; + for (const [value, { label }] of this.formTypes) { + options.push({ value, label }); + } + return options; + } + + async load() { + this.app.schemaSettingsManager.add(publicFormBlockSettings); + this.registerFormType('form', { + label: 'Form', + uiSchema: formSchemaCallback, + }); + this.app.router.add('public-forms', { + path: '/public-forms/:name', + Component: PublicFormPage, + }); + this.app.pluginSettingsManager.add('public-forms', { + title: `{{t("Public forms", { ns: "${NAMESPACE}" })}}`, + + icon: 'TableOutlined', + Component: AdminPublicFormList, + }); + this.app.pluginSettingsManager.add(`public-forms/:name`, { + title: false, + pluginKey: 'public-forms', + isTopLevel: false, + Component: AdminPublicFormPage, + }); + } +} + +export default PluginPublicFormsClient; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/locale.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/locale.ts new file mode 100644 index 000000000..38748f63d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/locale.ts @@ -0,0 +1,30 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useApp } from '@nocobase/client'; +import { useTranslation } from 'react-i18next'; +// @ts-ignore +import pkg from './../../package.json'; + +export const NAMESPACE = 'public-forms'; + +export function useT() { + const app = useApp(); + return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] }); +} + +export function tStr(key: string) { + return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`; +} + +export function usePublicFormTranslation() { + return useTranslation([NAMESPACE, 'client'], { + nsMode: 'fallback', + }); +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/createActionSchema.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/createActionSchema.ts new file mode 100644 index 000000000..cb2f20282 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/createActionSchema.ts @@ -0,0 +1,94 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ +import { NAMESPACE } from '../locale'; + +export const createActionSchema = { + type: 'void', + 'x-component': 'Action', + title: `{{t("Add New", { ns: "${NAMESPACE}" })}}`, + 'x-align': 'right', + 'x-component-props': { + type: 'primary', + icon: 'PlusOutlined', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + title: "{{t('Add New')}}", + 'x-decorator': 'Form', + properties: { + form: { + type: 'void', + properties: { + title: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + }, + collection: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + }, + type: { + type: 'string', + 'x-decorator': 'FormItem', + title: `{{t("Type",{ns:"public-forms"})}}`, + 'x-component': 'CollectionField', + default: 'form', + enum: '{{ formTypes }}', + }, + description: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + }, + enabledPassword: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + default: false, + }, + password: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + 'x-reactions': { + dependencies: ['enabledPassword'], + fulfill: { + state: { + required: '{{$deps[0]}}', + }, + }, + }, + }, + enabled: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + default: true, + }, + }, + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + submit: { + title: 'Submit', + 'x-component': 'Action', + 'x-use-component-props': 'useSubmitActionProps', + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/editActionSchema.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/editActionSchema.ts new file mode 100644 index 000000000..6a7ffe0c5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/editActionSchema.ts @@ -0,0 +1,96 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const editActionSchema = { + type: 'void', + title: 'Edit', + 'x-component': 'Action.Link', + 'x-component-props': { + openMode: 'drawer', + icon: 'EditOutlined', + }, + properties: { + drawer: { + type: 'void', + title: 'Edit', + 'x-component': 'Action.Drawer', + 'x-decorator': 'FormV2', + 'x-use-decorator-props': 'useEditFormProps', + properties: { + form: { + type: 'void', + properties: { + title: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + }, + collection: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + 'x-component-props': { + disabled: true, + }, + }, + type: { + type: 'string', + 'x-decorator': 'FormItem', + title: `{{t("Type",{ns:"public-forms"})}}`, + 'x-component': 'Radio.Group', + default: 'form', + enum: '{{ formTypes }}', + }, + description: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + }, + enabledPassword: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + default: false, + }, + password: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + 'x-reactions': { + dependencies: ['enabledPassword'], + fulfill: { + state: { + required: '{{$deps[0]}}', + }, + }, + }, + }, + enabled: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + default: true, + }, + }, + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + submit: { + title: 'Submit', + 'x-component': 'Action', + 'x-use-component-props': 'useSubmitActionProps', + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/formSchemaCallback.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/formSchemaCallback.ts new file mode 100644 index 000000000..04a92d244 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/formSchemaCallback.ts @@ -0,0 +1,47 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const formSchemaCallback = (options) => ({ + type: 'void', + 'x-toolbar': 'BlockSchemaToolbar', + 'x-toolbar-props': { + draggable: false, + }, + 'x-settings': 'blockSettings:publicForm', + 'x-component': 'CardItem', + 'x-decorator': 'FormBlockProvider', + 'x-decorator-props': { + collection: options.collection, + dataSource: options.dataSource, + type: 'publicForm', + }, + 'x-use-decorator-props': 'useCreateFormBlockDecoratorProps', + properties: { + a69vmspkv8h: { + type: 'void', + 'x-component': 'FormV2', + 'x-use-component-props': 'useCreateFormBlockProps', + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'form:configureFields', + }, + l9xfwp6cfh1: { + type: 'void', + 'x-component': 'ActionBar', + 'x-initializer': 'createForm:configureActions', + 'x-component-props': { + layout: 'one-column', + }, + }, + }, + }, + }, +}); diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/index.ts b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/index.ts new file mode 100644 index 000000000..b33a567fa --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './publicForms'; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/publicForms.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/publicForms.tsx new file mode 100644 index 000000000..1eaaa53f1 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/schemas/publicForms.tsx @@ -0,0 +1,252 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { uid } from '@formily/shared'; +import { ISchema } from '@nocobase/client'; +import { publicFormsCollection } from '../collections'; +import { ConfigureLink } from '../components/ConfigureLink'; +import { createActionSchema } from './createActionSchema'; +import { editActionSchema } from './editActionSchema'; +import { NAMESPACE } from '../locale'; + +export const publicFormsSchema: ISchema = { + type: 'void', + name: uid(), + 'x-component': 'CardItem', + 'x-decorator': 'TableBlockProvider', + 'x-decorator-props': { + collection: publicFormsCollection.name, + action: 'list', + params: { + sort: '-createdAt', + appends: ['createdBy', 'updatedBy'], + }, + showIndex: true, + dragSort: false, + rowKey: 'key', + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 20, + }, + }, + properties: { + filter: { + type: 'void', + title: '{{ t("Filter") }}', + default: { + $and: [{ title: { $includes: '' } }], + }, + 'x-action': 'filter', + 'x-component': 'Filter.Action', + 'x-use-component-props': 'useFilterActionProps', + 'x-component-props': { + icon: 'FilterOutlined', + }, + 'x-align': 'left', + }, + refresh: { + type: 'void', + title: '{{ t("Refresh") }}', + 'x-component': 'Action', + 'x-use-component-props': 'useRefreshActionProps', + 'x-component-props': { + icon: 'ReloadOutlined', + }, + }, + destroy: { + title: '{{ t("Delete") }}', + 'x-action': 'destroy', + 'x-component': 'Action', + 'x-use-component-props': 'useBulkDestroyActionProps', + 'x-component-props': { + icon: 'DeleteOutlined', + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + }, + }, + createActionSchema, + }, + }, + table: { + type: 'array', + 'x-component': 'TableV2', + 'x-use-component-props': 'useTableBlockProps', + 'x-component-props': { + rowKey: publicFormsCollection.filterTargetKey, + rowSelection: { + type: 'checkbox', + }, + }, + properties: { + title: { + type: 'void', + title: '{{ t("Title") }}', + 'x-component': 'TableV2.Column', + 'x-component-props': { + width: 170, + }, + properties: { + title: { + type: 'string', + 'x-component': 'CollectionField', + 'x-pattern': 'readPretty', + }, + }, + }, + collection: { + type: 'void', + title: '{{ t("Collection") }}', + 'x-component': 'TableV2.Column', + 'x-component-props': { + width: 160, + }, + properties: { + collection: { + type: 'string', + 'x-component': 'CollectionField', + 'x-pattern': 'readPretty', + }, + }, + }, + column2: { + type: 'void', + title: `{{t("Type", { ns: "${NAMESPACE}" })}}`, + 'x-component': 'TableV2.Column', + 'x-component-props': { + width: 100, + }, + properties: { + type: { + type: 'string', + 'x-component': 'Radio.Group', + 'x-pattern': 'readPretty', + enum: '{{ formTypes }}', + }, + }, + }, + column3: { + type: 'void', + title: '{{ t("Enabled") }}', + 'x-component': 'TableV2.Column', + 'x-component-props': { + width: 80, + }, + properties: { + enabled: { + type: 'string', + 'x-component': 'CollectionField', + 'x-pattern': 'readPretty', + }, + }, + }, + description: { + type: 'void', + title: '{{ t("Description") }}', + 'x-component': 'TableV2.Column', + properties: { + description: { + type: 'string', + 'x-component': 'CollectionField', + 'x-pattern': 'readPretty', + }, + }, + }, + // column4: { + // type: 'void', + // 'x-component': 'TableV2.Column', + // title: "{{t('Created at')}}", + // properties: { + // createdAt: { + // type: 'date', + // 'x-component': 'CollectionField', + // 'x-pattern': 'readPretty', + // }, + // }, + // }, + // column5: { + // type: 'void', + // 'x-component': 'TableV2.Column', + // title: '{{t("Created by")}}', + // 'x-component-props': { + // width: 110, + // }, + // properties: { + // createdBy: { + // type: 'object', + // 'x-component': 'CollectionField', + // 'x-pattern': 'readPretty', + // }, + // }, + // }, + // column6: { + // type: 'void', + // 'x-component': 'TableV2.Column', + // title: "{{t('Updated at')}}", + // properties: { + // updatedAt: { + // type: 'string', + // 'x-component': 'CollectionField', + // 'x-pattern': 'readPretty', + // }, + // }, + // }, + // column7: { + // type: 'void', + // 'x-component': 'TableV2.Column', + // title: '{{t("Last updated by")}}', + // 'x-component-props': { + // width: 110, + // }, + // properties: { + // updatedBy: { + // type: 'date', + // 'x-component': 'CollectionField', + // 'x-pattern': 'readPretty', + // }, + // }, + // }, + actions: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-component': 'TableV2.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + configure: { + type: 'void', + title: 'Configure', + 'x-component': ConfigureLink, + }, + editActionSchema, + delete: { + type: 'void', + title: 'Delete', + 'x-component': 'Action.Link', + 'x-use-component-props': 'useDeleteActionProps', + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/settings/index.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/settings/index.tsx new file mode 100644 index 000000000..cdfd26e90 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/settings/index.tsx @@ -0,0 +1,40 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { + SchemaSettings, + SchemaSettingsBlockHeightItem, + SchemaSettingsBlockTitleItem, + SchemaSettingsLinkageRules, + useCollection, +} from '@nocobase/client'; + +export const publicFormBlockSettings = new SchemaSettings({ + name: 'blockSettings:publicForm', + items: [ + { + name: 'title', + Component: SchemaSettingsBlockTitleItem, + }, + // { + // name: 'setTheBlockHeight', + // Component: SchemaSettingsBlockHeightItem, + // }, + { + name: 'linkageRules', + Component: SchemaSettingsLinkageRules, + useComponentProps() { + const { name } = useCollection(); + return { + collectionName: name, + }; + }, + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/index.ts b/packages/plugins/@nocobase/plugin-public-forms/src/index.ts new file mode 100644 index 000000000..c1f3dc775 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/index.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './server'; +export { default } from './server'; +// diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-public-forms/src/locale/zh-CN.json new file mode 100644 index 000000000..f470abe68 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/locale/zh-CN.json @@ -0,0 +1,14 @@ +{ + "Enable form": "启用表单", + "Public forms": "公开表单", + "Add New": "添加", + "Type": "类型", + "Open form": "查看公开表单", + "Set password": "设置密码", + "Copy link": "复制链接", + "QR code": "二维码", + "The form is not enabled and cannot be accessed": "该表单未启用,无法访问", + "Link copied successfully": "复制地址成功", + "After successful submission": "提交成功后", + "Enable password": "启用密码" +} diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/server/collections/publicForms.ts b/packages/plugins/@nocobase/plugin-public-forms/src/server/collections/publicForms.ts new file mode 100644 index 000000000..0fd24e80c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/server/collections/publicForms.ts @@ -0,0 +1,52 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineCollection } from '@nocobase/database'; + +export default defineCollection({ + name: 'publicForms', + filterTargetKey: 'key', + createdBy: true, + updatedBy: true, + fields: [ + { + type: 'uid', + name: 'key', + unique: true, + }, + { + type: 'string', + name: 'title', + }, + { + type: 'string', + name: 'type', + }, + { + type: 'string', + name: 'collection', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'boolean', + name: 'enabled', + }, + { + type: 'boolean', + name: 'enabledPassword', + }, + { + type: 'string', + name: 'password', + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/server/hook.ts b/packages/plugins/@nocobase/plugin-public-forms/src/server/hook.ts new file mode 100644 index 000000000..23831400e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/server/hook.ts @@ -0,0 +1,106 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export function getAssociationPath(str) { + const lastIndex = str.lastIndexOf('.'); + if (lastIndex !== -1) { + return str.substring(0, lastIndex); + } + return str; +} + +/** + * 为多层级的关系字段补充上父级字段 + * e.g. ['a', 'b.c'] => ['a', 'b', 'b.c'] + * @param appends + * @returns + */ +export function fillParentFields(appends: Set) { + const depFields = Array.from(appends).filter((field) => field?.includes?.('.')); + + depFields.forEach((field) => { + const fields = field.split('.'); + fields.pop(); + const parentField = fields.join('.'); + appends.add(parentField); + }); + + return appends; +} + +export const parseAssociationNames = (dataSourceKey: string, collectionName: string, app: any, fieldSchema: any) => { + let appends = new Set([]); + const dataSource = app.dataSourceManager.dataSources.get(dataSourceKey); + const _getAssociationAppends = (schema, str) => { + // 定义 reduceProperties 函数来遍历 properties + const reduceProperties = (schema, reducer, initialValue) => { + if (!schema || typeof schema !== 'object') { + return initialValue; + } + if (schema.properties && typeof schema.properties === 'object') { + for (const key in schema.properties) { + if (schema.properties[key]) { + const property = schema.properties[key]; + // 调用 reducer 函数 + initialValue = reducer(initialValue, property, key); + // 递归处理嵌套 properties + initialValue = reduceProperties(property, reducer, initialValue); + } + } + } + return initialValue; + }; + + // 定义自定义的 reducer 函数,模仿你的原始逻辑 + const customReducer = (pre, s, key) => { + const prefix = pre || str; + const collection = dataSource.collectionManager.getCollection( + s?.['x-collection-field']?.split('.')?.[0] || collectionName, + ); + const collectionField = s['x-collection-field'] && collection.getField(s['x-collection-field']?.split('.')[1]); + const isAssociationField = + collectionField && + ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(collectionField.type); + if (collectionField && isAssociationField) { + appends.add(collectionField.target); + // 如果组件类型是 'Nester'、'SubTable' 或 'PopoverNester',递归调用 _getAssociationAppends + if (['Nester', 'SubTable', 'PopoverNester'].includes(s['x-component-props']?.mode)) { + const bufPrefix = prefix && prefix !== '' ? `${prefix}.${s.name}` : s.name; + _getAssociationAppends(s, bufPrefix); + } + } else if ( + ![ + 'ActionBar', + 'Action', + 'Action.Link', + 'Action.Modal', + 'Selector', + 'Viewer', + 'AddNewer', + 'AssociationField.Selector', + 'AssociationField.AddNewer', + 'TableField', + ].includes(s['x-component']) + ) { + _getAssociationAppends(s, str); + } + return pre; + }; + + // 使用 reduceProperties 遍历 schema + reduceProperties(schema, customReducer, str); + }; + const getAssociationAppends = () => { + appends = new Set([]); + _getAssociationAppends(fieldSchema.properties.form, ''); + appends = fillParentFields(appends); + return { appends: [...appends] }; + }; + return { getAssociationAppends }; +}; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/server/index.ts b/packages/plugins/@nocobase/plugin-public-forms/src/server/index.ts new file mode 100644 index 000000000..be989de7c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/server/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { default } from './plugin'; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-public-forms/src/server/plugin.ts new file mode 100644 index 000000000..947644126 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/server/plugin.ts @@ -0,0 +1,199 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; +import { Plugin } from '@nocobase/server'; +import { parseAssociationNames } from './hook'; + +class PasswordError extends Error {} + +export class PluginPublicFormsServer extends Plugin { + async parseCollectionData(formCollection, appends) { + const collection = this.db.getCollection(formCollection); + const collections = [ + { + name: collection.name, + fields: collection.getFields().map((v) => { + return { + ...v.options, + }; + }), + template: collection.options.template, + }, + ]; + return collections.concat( + appends.map((v) => { + const targetCollection = this.db.getCollection(v); + return { + name: targetCollection.name, + fields: targetCollection.getFields().map((v) => { + return { + ...v.options, + }; + }), + template: targetCollection.options.template, + }; + }), + ); + } + + async getMetaByTk(filterByTk: string, options: { password?: string; token?: string }) { + const { token, password } = options; + const publicForms = this.db.getRepository('publicForms'); + const uiSchema = this.db.getRepository('uiSchemas'); + const instance = await publicForms.findOne({ + filter: { + key: filterByTk, + }, + }); + if (!instance.get('enabled')) { + return null; + } + if (!token) { + if (instance.get('password') && instance.get('enabledPassword')) { + if (password === undefined) { + return { + passwordRequired: true, + }; + } + if (instance.get('password') !== password) { + throw new PasswordError('Please enter your password'); + } + } + } + const keys = instance.collection.split(':'); + const collectionName = keys.pop(); + const dataSourceKey = keys.pop() || 'main'; + const schema = await uiSchema.getJsonSchema(filterByTk); + const { getAssociationAppends } = parseAssociationNames(dataSourceKey, collectionName, this.app, schema); + const { appends } = getAssociationAppends(); + const collections = await this.parseCollectionData(collectionName, appends); + return { + dataSource: { + key: dataSourceKey, + displayName: dataSourceKey, + collections, + }, + token: this.app.authManager.jwt.sign({ + collectionName, + formKey: filterByTk, + targetCollections: appends, + }), + schema, + }; + } + + // TODO + getPublicFormsMeta = async (ctx, next) => { + const token = ctx.get('X-Form-Token'); + const { filterByTk, password } = ctx.action.params; + try { + ctx.body = await this.getMetaByTk(filterByTk, { password, token }); + } catch (error) { + if (error instanceof PasswordError) { + ctx.throw(401, error.message); + } else { + throw error; + } + } + await next(); + }; + + parseToken = async (ctx, next) => { + if (!ctx.action) { + return next(); + } + const { actionName, resourceName, params } = ctx.action; + // 有密码时,跳过 token + if (resourceName === 'publicForms' && actionName === 'getMeta' && params.password) { + return next(); + } + const jwt = this.app.authManager.jwt; + const token = ctx.get('X-Form-Token'); + if (token) { + try { + const tokenData = await jwt.decode(token); + ctx.PublicForm = { + collectionName: tokenData.collectionName, + formKey: tokenData.formKey, + targetCollections: tokenData.targetCollections, + }; + + const publicForms = this.db.getRepository('publicForms'); + const instance = await publicForms.findOne({ + filter: { + key: tokenData.formKey, + }, + }); + if (!instance.get('enabled')) { + throw new Error('The form is not enabled'); + } + // 将 publicSubmit 转为 create(用于触发工作流的 Action 事件) + const actionName = ctx.action.actionName; + if (actionName === 'publicSubmit') { + ctx.action.actionName = 'create'; + } + } catch (error) { + ctx.throw(401, error.message); + } + } + await next(); + }; + + parseACL = async (ctx, next) => { + const { resourceName, actionName } = ctx.action; + if (ctx.PublicForm && ['create', 'list'].includes(actionName)) { + if (actionName === 'create') { + ctx.permission = { + skip: + ctx.PublicForm['collectionName'] === resourceName || + ctx.PublicForm['targetCollections'].includes(resourceName), + }; + } else { + ctx.permission = { + skip: ctx.PublicForm['targetCollections'].includes(resourceName), + }; + } + } else { + ctx.permission = { + skip: true, + }; + } + + await next(); + }; + + async load() { + this.app.acl.allow('publicForms', 'getMeta', 'public'); + this.app.resourceManager.registerActionHandlers({ + 'publicForms:getMeta': this.getPublicFormsMeta, + }); + this.app.dataSourceManager.afterAddDataSource((dataSource) => { + dataSource.resourceManager.use(this.parseToken, { + before: 'acl', + }); + dataSource.acl.use(this.parseACL, { + before: 'core', + }); + dataSource.resourceManager.registerActionHandlers({ + publicSubmit: dataSource.resourceManager.getRegisteredHandler('create'), + }); + }); + } + + async install() {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default PluginPublicFormsServer; diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 1f09e3b6e..77dbf2f08 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -47,6 +47,7 @@ "@nocobase/plugin-mock-collections": "1.4.0-alpha", "@nocobase/plugin-multi-app-manager": "1.4.0-alpha", "@nocobase/plugin-multi-app-share-collection": "1.4.0-alpha", + "@nocobase/plugin-public-forms": "1.4.0-alpha", "@nocobase/plugin-snapshot-field": "1.4.0-alpha", "@nocobase/plugin-system-settings": "1.4.0-alpha", "@nocobase/plugin-theme-editor": "1.4.0-alpha",