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 <katherine_15995@163.com>
This commit is contained in:
chenos 2024-09-26 17:12:16 +08:00 committed by GitHub
parent cdc188c8c8
commit c3e740b552
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2370 additions and 72 deletions

View File

@ -103,6 +103,13 @@ export const useACLContext = () => {
export const ACLActionParamsContext = createContext<any>({});
ACLActionParamsContext.displayName = 'ACLActionParamsContext';
export const ACLCustomContext = createContext<any>({});
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;

View File

@ -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' },
},
},

View File

@ -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',

View File

@ -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';
},
},
],
};

View File

@ -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';
},
},
{

View File

@ -9,3 +9,4 @@
export * from './BlockItem';
export * from './TestDesigner';
export * from './BlockItemCard';

View File

@ -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 (
<div>
<Tag>{displayValues.join(' / ')}</Tag>
</div>
);
});
export const DataSourceCollectionCascader = connect((props) => {
const dataSourceManager = useDataSourceManager();
const compile = useCompile();
@ -183,4 +235,4 @@ export const DataSourceCollectionCascader = connect((props) => {
[onChange],
);
return <Cascader showSearch {...others} options={options} value={path} onChange={handleChange} />;
});
}, mapReadPretty(DataSourceCollectionCascaderReadPretty));

View File

@ -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);

View File

@ -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<SchemaSettingsModalItemProps> = (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<SchemaSettingsModalItemProps> = (props)
if (hidden) {
return null;
}
return (
<SchemaSettingsItem
title={title}
@ -801,64 +803,70 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
() => {
return (
<CollectOperators defaultOperators={getOperators()}>
<BlockContext.Provider value={blockOptions}>
<VariablePopupRecordProvider
recordData={popupRecordVariable?.value}
collection={popupRecordVariable?.collection}
parent={{
recordData: parentPopupRecordVariable?.value,
collection: parentPopupRecordVariable?.collection,
}}
>
<CollectionRecordProvider record={noRecord ? null : record}>
<FormBlockContext.Provider value={formCtx}>
<SubFormProvider value={{ value: subFormValue, collection: subFormCollection }}>
<FormActiveFieldsProvider
name="form"
getActiveFieldsName={upLevelActiveFields?.getActiveFieldsName}
>
<LocationSearchContext.Provider value={locationSearch}>
<BlockRequestContext_deprecated.Provider value={ctx}>
<DataSourceApplicationProvider dataSourceManager={dm} dataSource={dataSourceKey}>
<AssociationOrCollectionProvider
allowNull
collection={collection.name}
association={association}
>
<SchemaComponentOptions scope={options.scope} components={options.components}>
<FormLayout
layout={'vertical'}
className={css`
// screen > 576px
@media (min-width: 576px) {
min-width: 520px;
}
<VariablesContext.Provider value={variableOptions}>
<BlockContext.Provider value={blockOptions}>
<VariablePopupRecordProvider
recordData={popupRecordVariable?.value}
collection={popupRecordVariable?.collection}
parent={{
recordData: parentPopupRecordVariable?.value,
collection: parentPopupRecordVariable?.collection,
}}
>
<CollectionRecordProvider record={noRecord ? null : record}>
<FormBlockContext.Provider value={formCtx}>
<SubFormProvider value={{ value: subFormValue, collection: subFormCollection }}>
<FormActiveFieldsProvider
name="form"
getActiveFieldsName={upLevelActiveFields?.getActiveFieldsName}
>
<LocationSearchContext.Provider value={locationSearch}>
<BlockRequestContext_deprecated.Provider value={ctx}>
<DataSourceApplicationProvider dataSourceManager={dm} dataSource={dataSourceKey}>
<AssociationOrCollectionProvider
allowNull
collection={collection.name}
association={association}
>
<SchemaComponentOptions scope={options.scope} components={options.components}>
<FormLayout
layout={'vertical'}
className={css`
// screen > 576px
@media (min-width: 576px) {
min-width: 520px;
}
// screen <= 576px
@media (max-width: 576px) {
min-width: 320px;
}
`}
>
<ApplicationContext.Provider value={app}>
<APIClientProvider apiClient={apiClient}>
<ConfigProvider locale={locale}>
<SchemaComponent components={components} scope={scope} schema={schema} />
</ConfigProvider>
</APIClientProvider>
</ApplicationContext.Provider>
</FormLayout>
</SchemaComponentOptions>
</AssociationOrCollectionProvider>
</DataSourceApplicationProvider>
</BlockRequestContext_deprecated.Provider>
</LocationSearchContext.Provider>
</FormActiveFieldsProvider>
</SubFormProvider>
</FormBlockContext.Provider>
</CollectionRecordProvider>
</VariablePopupRecordProvider>
</BlockContext.Provider>
// screen <= 576px
@media (max-width: 576px) {
min-width: 320px;
}
`}
>
<ApplicationContext.Provider value={app}>
<APIClientProvider apiClient={apiClient}>
<ConfigProvider locale={locale}>
<SchemaComponent
components={components}
scope={scope}
schema={schema}
/>
</ConfigProvider>
</APIClientProvider>
</ApplicationContext.Provider>
</FormLayout>
</SchemaComponentOptions>
</AssociationOrCollectionProvider>
</DataSourceApplicationProvider>
</BlockRequestContext_deprecated.Provider>
</LocationSearchContext.Provider>
</FormActiveFieldsProvider>
</SubFormProvider>
</FormBlockContext.Provider>
</CollectionRecordProvider>
</VariablePopupRecordProvider>
</BlockContext.Provider>
</VariablesContext.Provider>
</CollectOperators>
);
},

View File

@ -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,

View File

@ -49,7 +49,7 @@ const getFieldPath = (variablePath: string, variablesStore: Record<string, Varia
};
};
const VariablesProvider = ({ children }) => {
const VariablesProvider = ({ children, filterVariables }: any) => {
const ctxRef = useRef<Record<string, any>>({});
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],
);

View File

@ -87,6 +87,7 @@ export interface VariablesContextType {
localVariables?: VariableOption | VariableOption[],
) => Promise<CollectionFieldOptions_deprecated>;
removeVariable: (variableName: string) => void;
filterVariables?: (params) => boolean; //自定义过滤变量
}
export interface VariableOption {

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -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"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -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<WebAssembly.Instance>;
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;
}

View File

@ -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';

View File

@ -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,
// },
// },
],
};

View File

@ -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 (
<ExtendCollectionsProvider collections={[publicFormsCollection]}>
<SchemaComponentContext.Provider value={{ ...scCtx, designable: false }}>
<SchemaComponent
schema={publicFormsSchema}
scope={{ formTypes, useSubmitActionProps, useEditFormProps, useDeleteActionProps }}
/>
</SchemaComponentContext.Provider>
</ExtendCollectionsProvider>
);
};

View File

@ -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 (
<Popover
trigger={'hover'}
open={open}
onOpenChange={handleQRCodeOpen}
content={open ? <QRCode value={link} bordered={false} /> : ' '}
>
{t('QR code', { ns: NAMESPACE })}
</Popover>
);
};
export function AdminPublicFormPage() {
const params = useParams();
const { t } = usePublicFormTranslation();
const { theme } = useGlobalTheme();
const apiClient = useAPIClient();
const { data, loading, refresh } = useRequest<any>({
url: `publicForms:get/${params.name}`,
});
const { enabled, title, ...others } = data?.data || {};
if (loading) {
return <Spin />;
}
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 (
<SchemaComponentOptions components={{ Checkbox, Input, FormItem }}>
<FormLayout layout={'vertical'}>
<SchemaComponent
schema={{
properties: {
enabledPassword: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
title: t('Enabled password'),
},
password: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input.Password',
title: t('Password'),
'x-reactions': {
dependencies: ['enabledPassword'],
fulfill: {
state: {
required: '{{$deps[0]}}',
},
},
},
},
},
}}
/>
</FormLayout>
</SchemaComponentOptions>
);
},
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 (
<div>
<div
style={{
margin: '-24px',
padding: '10px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Breadcrumb
items={[
{
title: <Link to={`/admin/settings/public-forms`}>{t('Public forms', { ns: NAMESPACE })}</Link>,
},
{
title: title,
},
]}
/>
<Space>
<Link target={'_blank'} to={`/public-forms/${params.name}`}>
<Button disabled={!enabled} icon={<EyeOutlined />}>
{t('Open form', { ns: NAMESPACE })}
</Button>
</Link>
<Dropdown
menu={{
items: [
{
key: 'enabled',
label: (
<span
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ marginRight: '10px' }}>{t('Enable form', { ns: NAMESPACE })}</span>
<Switch
size={'small'}
checked={enabled}
onChange={(checked) => handleEditPublicForm({ enabled: checked })}
/>
</span>
),
},
{
key: 'password',
label: <span onClick={handleSetPassword}> {t('Set password')}</span>,
},
{
key: 'divider1',
type: 'divider',
},
{
key: 'copyLink',
label: <span onClick={handleCopyLink}>{t('Copy link')}</span>,
},
{
key: 'qrcode',
label: <PublicFormQRCode />,
},
],
}}
>
<Button icon={<SettingOutlined />}>{t('Settings')}</Button>
</Dropdown>
</Space>
</div>
<div style={{ maxWidth: 800, margin: '100px auto' }}>
<VariablesProvider
filterVariables={(v) => {
return !['$user', '$nRole', '$nToken', '$nURLSearchParams'].includes(v.key);
}}
>
<RemoteSchemaComponent
uid={params.name}
scope={{ useCreateActionProps: usePublicSubmitActionProps }}
components={{ PublicFormMessageProvider: (props) => props.children }}
/>
</VariablesProvider>
<PoweredBy />
</div>
</div>
);
}

View File

@ -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 <Link to={`/admin/settings/public-forms/${value}`}>{t('Configure')}</Link>;
}

View File

@ -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 (
<div>
<DataSourceApplicationProvider
dataSource={dataSource.key}
dataSourceManager={dataSourceManager}
instance={collectionManager}
>
{props.children}
</DataSourceApplicationProvider>
</div>
);
}
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 <APIClientProvider apiClient={apiClient}>{children}</APIClientProvider>;
}
export const PublicFormMessageContext = createContext<any>({});
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 (
<PublicFormMessageContext.Provider value={{ showMessage, setShowMessage }}>
{children}
</PublicFormMessageContext.Provider>
);
};
function InternalPublicForm() {
const params = useParams();
const apiClient = useAPIClient();
const { error, data, loading, run } = useRequest<any>(
{
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 (
<div>
<Modal
centered
title="Password"
open={true}
cancelButtonProps={{
hidden: true,
}}
onOk={() => {
run({
password: pwd,
});
}}
>
<Input.Password
onChange={(e) => {
setPwd(e.target.value);
}}
/>
</Modal>
</div>
);
}
if (loading) {
return <Spin />;
}
if (!data?.data) {
return <UnEnabledFormPlaceholder />;
}
return (
<ACLCustomContext.Provider value={{ allowAll: true }}>
<PublicAPIClientProvider>
<div
style={{
minHeight: '100vh',
background: PageBackgroundColor,
height: '100%',
overflow: 'auto',
}}
>
<div
style={{ maxWidth: 800, margin: '0 auto' }}
className={css`
@media (min-width: 1025px) {
padding-top: 10vh;
}
padding-top: 0px;
`}
>
<PublicPublicFormProvider dataSource={data?.data?.dataSource}>
<VariablesProvider>
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
<SchemaComponent
schema={data?.data?.schema}
scope={{
useCreateActionProps: usePublicSubmitActionProps,
}}
components={{ PublicFormMessageProvider: PublicFormMessageProvider }}
/>
</SchemaComponentContext.Provider>
</VariablesProvider>
</PublicPublicFormProvider>
<div style={{ marginBottom: '20px' }}>
<PoweredBy />
</div>
</div>
</div>
</PublicAPIClientProvider>
</ACLCustomContext.Provider>
);
}
export function PublicFormPage() {
return <InternalPublicForm />;
}

View File

@ -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 (
<BlockItemCard style={{ boxShadow: 'unset' }}>
<Result status="403" subTitle={t(`The form is not enabled and cannot be accessed`, { ns: NAMESPACE })} />
</BlockItemCard>
);
};

View File

@ -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';
//

View File

@ -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!');
},
};
}

View File

@ -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,
};
};

View File

@ -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;
}
},
};
};

View File

@ -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);
},
};
};

View File

@ -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;

View File

@ -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',
});
}

View File

@ -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',
},
},
},
},
},
},
};

View File

@ -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',
},
},
},
},
},
},
};

View File

@ -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',
},
},
},
},
},
});

View File

@ -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';

View File

@ -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',
},
},
},
},
},
},
},
},
};

View File

@ -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,
};
},
},
],
});

View File

@ -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';
//

View File

@ -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": "启用密码"
}

View File

@ -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',
},
],
});

View File

@ -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<string>) {
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 };
};

View File

@ -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';

View File

@ -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<UiSchemaRepository>('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;

View File

@ -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",