mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(client-v2): align collection template configuration (#9711)
* fix(client-v2): align collection template configuration * fix(plugin-comments): add comments block for client v2
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './dist/client-v2';
|
||||
export { default } from './dist/client-v2';
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client-v2/index.js');
|
||||
@@ -30,7 +30,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "2.x",
|
||||
"@nocobase/client-v2": "2.x",
|
||||
"@nocobase/database": "2.x",
|
||||
"@nocobase/flow-engine": "2.x",
|
||||
"@nocobase/plugin-data-source-manager": "2.x",
|
||||
"@nocobase/server": "2.x",
|
||||
"@nocobase/test": "2.x"
|
||||
},
|
||||
|
||||
+948
@@ -0,0 +1,948 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This program is offered under a commercial license.
|
||||
* For more information, see <https://www.nocobase.com/agreement>
|
||||
*/
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { randomId, useFlowContext } from '@nocobase/flow-engine';
|
||||
import type { CollectionTemplateConfigureItemProps } from '@nocobase/plugin-data-source-manager/client-v2';
|
||||
import { compileLegacyTemplate } from '@nocobase/plugin-data-source-manager/client-v2';
|
||||
import { useRequest } from 'ahooks';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Divider,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
notification as staticNotification,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { get } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from './locale';
|
||||
|
||||
const internalUnsupportedFieldsName = '__fdwUnsupportedFields';
|
||||
const mapFieldTypes = new Set(['lineString', 'point', 'circle', 'polygon']);
|
||||
|
||||
type DatabaseServerRecord = {
|
||||
database?: string;
|
||||
description?: string;
|
||||
host?: string;
|
||||
name: string;
|
||||
password?: string;
|
||||
port?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
type DatabaseServerFormValues = {
|
||||
database: string;
|
||||
description: string;
|
||||
host: string;
|
||||
name: string;
|
||||
password: string;
|
||||
port: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type DatabaseServerDialogState =
|
||||
| {
|
||||
mode: 'create';
|
||||
}
|
||||
| {
|
||||
mode: 'edit';
|
||||
record: DatabaseServerRecord;
|
||||
}
|
||||
| null;
|
||||
|
||||
type DatabaseServerSelectOption = {
|
||||
label: string;
|
||||
server: DatabaseServerRecord;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type Translate = (key: string, options?: Record<string, any>) => string;
|
||||
|
||||
type FieldInterfaceRecord = {
|
||||
availableTypes?: string[];
|
||||
default?: {
|
||||
type?: string;
|
||||
uiSchema?: Record<string, unknown>;
|
||||
};
|
||||
group?: string;
|
||||
name?: string;
|
||||
title?: React.ReactNode;
|
||||
};
|
||||
|
||||
type FieldInterfaceManager = {
|
||||
getFieldInterface?: (name?: string) => FieldInterfaceRecord | undefined;
|
||||
getFieldInterfaceGroups?: () => Record<string, { label?: React.ReactNode; order?: number }>;
|
||||
getFieldInterfaces?: () => FieldInterfaceRecord[];
|
||||
};
|
||||
|
||||
type FdwFieldRecord = {
|
||||
allowNull?: boolean;
|
||||
interface?: string | null;
|
||||
name: string;
|
||||
possibleTypes?: string[];
|
||||
primaryKey?: boolean;
|
||||
rawType?: string;
|
||||
type?: string;
|
||||
uiSchema?: Record<string, unknown>;
|
||||
unique?: boolean;
|
||||
};
|
||||
|
||||
function normalizeArrayResponse(response: unknown) {
|
||||
const payload = get(response, 'data.data');
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const nested = get(payload, 'data');
|
||||
return Array.isArray(nested) ? nested : [];
|
||||
}
|
||||
|
||||
function normalizeRemoteTableValue(value?: string) {
|
||||
if (!value) {
|
||||
return {
|
||||
remoteTableInfo: undefined,
|
||||
tableName: undefined,
|
||||
};
|
||||
}
|
||||
const [schema, tableName] = value.includes('.') ? value.split('.') : [undefined, value];
|
||||
return {
|
||||
remoteTableInfo: schema
|
||||
? {
|
||||
schema,
|
||||
tableName,
|
||||
}
|
||||
: {
|
||||
tableName,
|
||||
},
|
||||
tableName,
|
||||
};
|
||||
}
|
||||
|
||||
function omitRawTitle(uiSchema?: Record<string, unknown>) {
|
||||
const { rawTitle, ...rest } = uiSchema || {};
|
||||
return rest;
|
||||
}
|
||||
|
||||
function normalizeRemoteTablePayload(response: unknown) {
|
||||
const candidates = [response, get(response, 'data'), get(response, 'data.data'), get(response, 'data.data.data')];
|
||||
const payload = candidates.find(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
(Array.isArray((item as { fields?: unknown }).fields) ||
|
||||
Array.isArray((item as { unsupportedFields?: unknown }).unsupportedFields)),
|
||||
) as { fields?: FdwFieldRecord[]; unsupportedFields?: FdwFieldRecord[] } | undefined;
|
||||
|
||||
return {
|
||||
fields: Array.isArray(payload?.fields) ? payload.fields : [],
|
||||
unsupportedFields: Array.isArray(payload?.unsupportedFields) ? payload.unsupportedFields : [],
|
||||
};
|
||||
}
|
||||
|
||||
function getResponseRecord<T>(response: unknown) {
|
||||
return get(response, 'data.data') as T | undefined;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
const responseData = get(error, 'response.data');
|
||||
if (typeof responseData === 'string') {
|
||||
return responseData;
|
||||
}
|
||||
|
||||
return (
|
||||
get(error, 'errorFields.0.errors.0') ||
|
||||
get(responseData, 'errors.0.message') ||
|
||||
get(responseData, 'messages.0.message') ||
|
||||
get(responseData, 'messages.0') ||
|
||||
get(responseData, 'error.message') ||
|
||||
get(responseData, 'message') ||
|
||||
get(error, 'message')
|
||||
);
|
||||
}
|
||||
|
||||
function isFormValidationError(error: unknown) {
|
||||
return Array.isArray(get(error, 'errorFields'));
|
||||
}
|
||||
|
||||
function useFdwErrorNotification(t: Translate) {
|
||||
const { notification } = App.useApp();
|
||||
const tRef = useRef(t);
|
||||
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
return useCallback(
|
||||
(title: string, error: unknown) => {
|
||||
(notification || staticNotification).error({
|
||||
message: tRef.current(title),
|
||||
description: getErrorMessage(error) || tRef.current('Operation failed'),
|
||||
});
|
||||
},
|
||||
[notification],
|
||||
);
|
||||
}
|
||||
|
||||
function getDatabaseServerFormValues(record?: DatabaseServerRecord): Partial<DatabaseServerFormValues> {
|
||||
return record
|
||||
? {
|
||||
database: record.database,
|
||||
description: record.description,
|
||||
host: record.host,
|
||||
name: record.name,
|
||||
password: record.password,
|
||||
port: record.port,
|
||||
username: record.username,
|
||||
}
|
||||
: {
|
||||
name: randomId('s_'),
|
||||
};
|
||||
}
|
||||
|
||||
function stopSelectOptionAction(event: React.MouseEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function DatabaseServerFormModal(props: {
|
||||
state: DatabaseServerDialogState;
|
||||
onCancel: () => void;
|
||||
onSubmitted: (record: DatabaseServerRecord) => Promise<void> | void;
|
||||
}) {
|
||||
const ctx = useFlowContext();
|
||||
const t = useT();
|
||||
const showError = useFdwErrorNotification(t);
|
||||
const [form] = Form.useForm<DatabaseServerFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const open = !!props.state;
|
||||
const mode = props.state?.mode || 'create';
|
||||
|
||||
useEffect(() => {
|
||||
if (props.state) {
|
||||
form.setFieldsValue(getDatabaseServerFormValues(props.state.mode === 'edit' ? props.state.record : undefined));
|
||||
}
|
||||
}, [form, props.state]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
if (submitting || testing) {
|
||||
return;
|
||||
}
|
||||
form.resetFields();
|
||||
props.onCancel();
|
||||
}, [form, props, submitting, testing]);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
let values: DatabaseServerFormValues;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch (error) {
|
||||
if (!isFormValidationError(error)) {
|
||||
showError('Test Connection', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
try {
|
||||
await ctx.api.resource('databaseServers').testConnection({ values });
|
||||
message.success(t('Connection successful'));
|
||||
} catch (error) {
|
||||
showError('Test Connection', error);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [ctx.api, form, showError, t]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
let values: DatabaseServerFormValues;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch (error) {
|
||||
if (!isFormValidationError(error)) {
|
||||
showError('Operation failed', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const resource = ctx.api.resource('databaseServers');
|
||||
let response: unknown;
|
||||
if (mode === 'create') {
|
||||
response = await resource.create({ values });
|
||||
} else {
|
||||
response = await resource.update({ filterByTk: values.name, values });
|
||||
}
|
||||
const record = getResponseRecord<DatabaseServerRecord>(response) || values;
|
||||
message.success(t('Saved successfully'));
|
||||
await props.onSubmitted(record);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
showError('Operation failed', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [ctx.api, form, mode, props, showError, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
destroyOnClose
|
||||
open={open}
|
||||
title={mode === 'create' ? t('Create database server') : t('Edit database server')}
|
||||
width={520}
|
||||
onCancel={closeDialog}
|
||||
footer={
|
||||
<Space>
|
||||
<Button loading={testing} onClick={() => handleTestConnection()}>
|
||||
{t('Test Connection')}
|
||||
</Button>
|
||||
<Button onClick={closeDialog}>{t('Cancel')}</Button>
|
||||
<Button type="primary" loading={submitting} onClick={() => handleSubmit()}>
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Form.Item name="description" label={t('Display name')} rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Server name')}
|
||||
extra={t(
|
||||
'Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.',
|
||||
)}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
pattern: /^[A-Za-z][A-Za-z0-9_]*$/,
|
||||
message: t('Support letters, numbers and underscores, must start with an letter.'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="host" label={t('Host')} rules={[{ required: true }]}>
|
||||
<Input.TextArea autoSize={{ minRows: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="port" label={t('Port')} rules={[{ required: true }]}>
|
||||
<Input.TextArea autoSize={{ minRows: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="database" label={t('Database')} rules={[{ required: true }]}>
|
||||
<Input.TextArea autoSize={{ minRows: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label={t('Username')} rules={[{ required: true }]}>
|
||||
<Input.TextArea autoSize={{ minRows: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={t('Password')} rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldInterfaceOptions(manager: FieldInterfaceManager) {
|
||||
const groups = manager?.getFieldInterfaceGroups?.() || {};
|
||||
const interfaces = manager?.getFieldInterfaces?.() || [];
|
||||
const grouped = new Map<
|
||||
string,
|
||||
Array<{ availableTypes?: string[]; defaultType?: string; label: React.ReactNode; value: string }>
|
||||
>();
|
||||
|
||||
interfaces
|
||||
.filter(
|
||||
(fieldInterface) => fieldInterface.name && !['relation', 'systemInfo'].includes(String(fieldInterface.group)),
|
||||
)
|
||||
.forEach((fieldInterface) => {
|
||||
const group = String(fieldInterface.group || 'other');
|
||||
const items = grouped.get(group) || [];
|
||||
items.push({
|
||||
value: String(fieldInterface.name),
|
||||
label: fieldInterface.title || String(fieldInterface.name),
|
||||
availableTypes: fieldInterface.availableTypes,
|
||||
defaultType: fieldInterface.default?.type,
|
||||
});
|
||||
grouped.set(group, items);
|
||||
});
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.sort(([groupA], [groupB]) => (groups[groupA]?.order ?? 0) - (groups[groupB]?.order ?? 0))
|
||||
.map(([group, options]) => ({
|
||||
label: groups[group]?.label || group,
|
||||
options,
|
||||
}));
|
||||
}
|
||||
|
||||
function getDefaultInterfaceName(fieldType: string | undefined, options: ReturnType<typeof getFieldInterfaceOptions>) {
|
||||
for (const group of options) {
|
||||
const option = group.options.find((item) => {
|
||||
if (!fieldType) {
|
||||
return true;
|
||||
}
|
||||
return item.availableTypes?.includes(fieldType) || item.defaultType === fieldType;
|
||||
});
|
||||
if (option) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRemoteFields(
|
||||
fields: FdwFieldRecord[],
|
||||
manager: FieldInterfaceManager,
|
||||
interfaceOptions: ReturnType<typeof getFieldInterfaceOptions>,
|
||||
) {
|
||||
return fields.map((field) => {
|
||||
const fieldInterface = field.interface || getDefaultInterfaceName(field.type, interfaceOptions);
|
||||
const defaultConfig = manager?.getFieldInterface?.(fieldInterface)?.default || {};
|
||||
return {
|
||||
...field,
|
||||
interface: fieldInterface,
|
||||
uiSchema: {
|
||||
...defaultConfig.uiSchema,
|
||||
...omitRawTitle(field.uiSchema),
|
||||
title: field.uiSchema?.title || field.name,
|
||||
required: !field.allowNull,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function FdwRemoteServerConfigureItem(props: CollectionTemplateConfigureItemProps) {
|
||||
const ctx = useFlowContext();
|
||||
const t = useT();
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
const [dialogState, setDialogState] = useState<DatabaseServerDialogState>(null);
|
||||
const selectedServerName = Form.useWatch('remoteServerName', props.form);
|
||||
const serversRequest = useRequest(async () => {
|
||||
const response = await ctx.api.resource('databaseServers').list();
|
||||
return normalizeArrayResponse(response) as DatabaseServerRecord[];
|
||||
});
|
||||
|
||||
const options = useMemo<DatabaseServerSelectOption[]>(
|
||||
() =>
|
||||
(serversRequest.data || []).map((server) => ({
|
||||
value: server.name,
|
||||
label: server.description || server.name,
|
||||
server,
|
||||
})),
|
||||
[serversRequest.data],
|
||||
);
|
||||
|
||||
const changeServer = useCallback(
|
||||
(serverName?: string) => {
|
||||
props.form.setFieldsValue({
|
||||
remoteServerName: serverName,
|
||||
remoteTableName: undefined,
|
||||
remoteTableInfo: undefined,
|
||||
fields: [],
|
||||
[internalUnsupportedFieldsName]: [],
|
||||
});
|
||||
},
|
||||
[props.form],
|
||||
);
|
||||
|
||||
const refreshServers = useCallback(async () => {
|
||||
if (serversRequest.refreshAsync) {
|
||||
await serversRequest.refreshAsync();
|
||||
return;
|
||||
}
|
||||
serversRequest.refresh();
|
||||
}, [serversRequest]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(server: DatabaseServerRecord) => {
|
||||
setSelectOpen(false);
|
||||
Modal.confirm({
|
||||
title: t('Are you sure you want to delete it?'),
|
||||
onOk: async () => {
|
||||
await ctx.api.resource('databaseServers').destroy({ filterByTk: server.name });
|
||||
message.success(t('Saved successfully'));
|
||||
if (selectedServerName === server.name) {
|
||||
changeServer(undefined);
|
||||
}
|
||||
await refreshServers();
|
||||
},
|
||||
});
|
||||
},
|
||||
[changeServer, ctx.api, refreshServers, selectedServerName, t],
|
||||
);
|
||||
|
||||
const openCreateDialog = useCallback(() => {
|
||||
setSelectOpen(false);
|
||||
setDialogState({ mode: 'create' });
|
||||
}, []);
|
||||
|
||||
const openEditDialog = useCallback((record: DatabaseServerRecord) => {
|
||||
setSelectOpen(false);
|
||||
setDialogState({ mode: 'edit', record });
|
||||
}, []);
|
||||
|
||||
const handleSubmitted = useCallback(
|
||||
async (record: DatabaseServerRecord) => {
|
||||
setDialogState(null);
|
||||
await refreshServers();
|
||||
if (record.name) {
|
||||
changeServer(record.name);
|
||||
}
|
||||
},
|
||||
[changeServer, refreshServers],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="remoteServerName" label={t('Database server')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
allowClear
|
||||
disabled={props.mode === 'edit'}
|
||||
dropdownRender={(menu) => (
|
||||
<div>
|
||||
{menu}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Space style={{ padding: '0 8px 4px' }}>
|
||||
<Button icon={<PlusOutlined />} type="link" onClick={openCreateDialog}>
|
||||
{t('Create database server')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
loading={serversRequest.loading}
|
||||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
onChange={changeServer}
|
||||
onDropdownVisibleChange={setSelectOpen}
|
||||
open={selectOpen}
|
||||
optionFilterProp="label"
|
||||
optionRender={({ data, label }) => {
|
||||
const server = (data as DatabaseServerSelectOption).server;
|
||||
return (
|
||||
<div style={{ alignItems: 'center', display: 'flex', gap: 8 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
<Space size={4} style={{ color: '#1677FF' }}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={(event) => {
|
||||
stopSelectOptionAction(event);
|
||||
openEditDialog(server);
|
||||
}}
|
||||
onMouseDown={stopSelectOptionAction}
|
||||
>
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
<Divider type="vertical" />
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={(event) => {
|
||||
stopSelectOptionAction(event);
|
||||
handleDelete(server);
|
||||
}}
|
||||
onMouseDown={stopSelectOptionAction}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
<DatabaseServerFormModal
|
||||
state={dialogState}
|
||||
onCancel={() => setDialogState(null)}
|
||||
onSubmitted={handleSubmitted}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FdwRemoteTableConfigureItem(props: CollectionTemplateConfigureItemProps) {
|
||||
const ctx = useFlowContext();
|
||||
const t = useT();
|
||||
const showError = useFdwErrorNotification(t);
|
||||
const manager = ctx.dataSourceManager.collectionFieldInterfaceManager as FieldInterfaceManager;
|
||||
const remoteServerName = Form.useWatch('remoteServerName', props.form);
|
||||
const fieldOptions = useMemo(() => getFieldInterfaceOptions(manager), [manager]);
|
||||
const [loadingFields, setLoadingFields] = useState(false);
|
||||
const autoNameRef = useRef<string>();
|
||||
const tablesRequest = useRequest(
|
||||
async (serverName: string) => {
|
||||
const response = await ctx.api.resource(`databaseServers/${serverName}/tables`).list();
|
||||
return normalizeArrayResponse(response).map((table) => {
|
||||
const item = table as { name?: string; schema?: string };
|
||||
const value = item.schema ? `${item.schema}.${item.name}` : item.name;
|
||||
return {
|
||||
value,
|
||||
label: value,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onError(error) {
|
||||
showError('Remote table', error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const loadFields = useCallback(
|
||||
async (tableName: string) => {
|
||||
if (!remoteServerName || !tableName) {
|
||||
props.form.setFieldsValue({
|
||||
fields: [],
|
||||
[internalUnsupportedFieldsName]: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingFields(true);
|
||||
try {
|
||||
const response = await ctx.api.resource(`databaseServers/${remoteServerName}/tables`).get({
|
||||
filterByTk: tableName,
|
||||
});
|
||||
const payload = normalizeRemoteTablePayload(response);
|
||||
props.form.setFieldsValue({
|
||||
fields: normalizeRemoteFields(payload.fields, manager, fieldOptions),
|
||||
[internalUnsupportedFieldsName]: payload.unsupportedFields,
|
||||
});
|
||||
} catch (error) {
|
||||
props.form.setFieldsValue({
|
||||
fields: [],
|
||||
[internalUnsupportedFieldsName]: [],
|
||||
});
|
||||
showError('Fields', error);
|
||||
} finally {
|
||||
setLoadingFields(false);
|
||||
}
|
||||
},
|
||||
[ctx.api, fieldOptions, manager, props.form, remoteServerName, showError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteServerName) {
|
||||
tablesRequest.run(remoteServerName);
|
||||
}
|
||||
}, [remoteServerName, tablesRequest]);
|
||||
|
||||
return (
|
||||
<Spin spinning={loadingFields}>
|
||||
<Form.Item name="remoteTableName" label={t('Remote table')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
allowClear
|
||||
disabled={props.mode === 'edit' || !remoteServerName}
|
||||
loading={tablesRequest.loading}
|
||||
optionFilterProp="label"
|
||||
options={tablesRequest.data || []}
|
||||
showSearch
|
||||
onChange={(value?: string) => {
|
||||
const { remoteTableInfo, tableName } = normalizeRemoteTableValue(value);
|
||||
props.form.setFieldsValue({
|
||||
remoteTableName: value,
|
||||
remoteTableInfo,
|
||||
fields: [],
|
||||
[internalUnsupportedFieldsName]: [],
|
||||
});
|
||||
|
||||
if (tableName) {
|
||||
const currentName = props.form.getFieldValue('name');
|
||||
if (
|
||||
!props.form.isFieldTouched('name') ||
|
||||
currentName === autoNameRef.current ||
|
||||
/^t_[A-Za-z0-9]+$/.test(currentName)
|
||||
) {
|
||||
autoNameRef.current = tableName;
|
||||
props.form.setFieldValue('name', tableName);
|
||||
}
|
||||
loadFields(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
export function FdwFieldsConfigureItem(props: CollectionTemplateConfigureItemProps) {
|
||||
const ctx = useFlowContext();
|
||||
const t = useT();
|
||||
const manager = ctx.dataSourceManager.collectionFieldInterfaceManager as FieldInterfaceManager;
|
||||
const watchedFields = Form.useWatch('fields', props.form);
|
||||
const unsupportedFields = Form.useWatch(internalUnsupportedFieldsName, { form: props.form, preserve: true }) as
|
||||
| FdwFieldRecord[]
|
||||
| undefined;
|
||||
const fields = useMemo(() => (Array.isArray(watchedFields) ? watchedFields : []), [watchedFields]);
|
||||
const fieldOptions = useMemo(() => getFieldInterfaceOptions(manager), [manager]);
|
||||
|
||||
const updateField = useCallback(
|
||||
(index: number, nextField: FdwFieldRecord) => {
|
||||
const nextFields = [...fields];
|
||||
nextFields.splice(index, 1, nextField);
|
||||
props.form.setFieldValue('fields', nextFields);
|
||||
},
|
||||
[fields, props.form],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<FdwFieldRecord> = [
|
||||
{
|
||||
title: t('Name'),
|
||||
dataIndex: 'name',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: t('Type'),
|
||||
dataIndex: 'type',
|
||||
width: 140,
|
||||
render: (value, record, index) =>
|
||||
record.possibleTypes?.length ? (
|
||||
<Select
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
popupMatchSelectWidth={false}
|
||||
options={record.possibleTypes.map((type) => ({ label: type, value: type }))}
|
||||
onChange={(nextType) => updateField(index, { ...record, type: nextType })}
|
||||
/>
|
||||
) : (
|
||||
<Tag>{value}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Interface'),
|
||||
dataIndex: 'interface',
|
||||
width: 180,
|
||||
render: (value, record, index) => (
|
||||
<Select
|
||||
allowClear
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ width: '100%' }}
|
||||
options={fieldOptions.map((group) => ({
|
||||
label: compileLegacyTemplate(group.label, t),
|
||||
options: group.options
|
||||
.filter((option) => !record.type || option.availableTypes?.includes(record.type))
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: compileLegacyTemplate(option.label, t),
|
||||
})),
|
||||
}))}
|
||||
value={value || undefined}
|
||||
onChange={(nextInterface) => {
|
||||
const fieldInterface = manager?.getFieldInterface?.(nextInterface);
|
||||
updateField(index, {
|
||||
...record,
|
||||
interface: nextInterface || null,
|
||||
type: fieldInterface?.default?.type || record.type,
|
||||
uiSchema: {
|
||||
...fieldInterface?.default?.uiSchema,
|
||||
title: record.uiSchema?.title || record.name,
|
||||
required: !record.allowNull,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Display name'),
|
||||
dataIndex: ['uiSchema', 'title'],
|
||||
width: 180,
|
||||
render: (_, record, index) => (
|
||||
<Input
|
||||
value={(record.uiSchema?.title as string) || record.name}
|
||||
onChange={(event) => {
|
||||
updateField(index, {
|
||||
...record,
|
||||
uiSchema: {
|
||||
...omitRawTitle(record.uiSchema),
|
||||
title: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="fields"
|
||||
hidden
|
||||
rules={[
|
||||
{
|
||||
validator(_, value) {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
return Promise.reject(new Error(t('Fields')));
|
||||
}
|
||||
if (value.some((field) => !field?.interface || !field?.uiSchema?.title)) {
|
||||
return Promise.reject(
|
||||
new Error(t('Fields can only be used correctly if they are defined with an interface.')),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{fields.length ? (
|
||||
<Form.Item
|
||||
label={t('Fields')}
|
||||
required
|
||||
extra={t('Fields can only be used correctly if they are defined with an interface.')}
|
||||
>
|
||||
<Table bordered columns={columns} dataSource={fields} pagination={false} rowKey="name" scroll={{ y: 300 }} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label={t('Fields')} required>
|
||||
<Alert showIcon message={t('Remote table')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{unsupportedFields?.length ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message={t('Unsupported fields')}
|
||||
description={unsupportedFields.map((field) => field.name).join(', ')}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FdwPreviewConfigureItem(props: CollectionTemplateConfigureItemProps) {
|
||||
const ctx = useFlowContext();
|
||||
const t = useT();
|
||||
const showError = useFdwErrorNotification(t);
|
||||
const remoteServerName = Form.useWatch('remoteServerName', props.form);
|
||||
const remoteTableName = Form.useWatch('remoteTableName', props.form);
|
||||
const watchedFields = Form.useWatch('fields', props.form);
|
||||
const fields = useMemo(() => (Array.isArray(watchedFields) ? watchedFields : []), [watchedFields]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataSource, setDataSource] = useState<Array<Record<string, unknown>>>([]);
|
||||
const fieldTypesKey = useMemo(() => {
|
||||
const fieldTypes = fields.reduce<Record<string, string>>((memo, field) => {
|
||||
if (field.type && mapFieldTypes.has(field.type)) {
|
||||
memo[field.name] = field.type;
|
||||
}
|
||||
return memo;
|
||||
}, {});
|
||||
return JSON.stringify(fieldTypes);
|
||||
}, [fields]);
|
||||
|
||||
const columns = useMemo<ColumnsType<Record<string, unknown>>>(() => {
|
||||
return fields
|
||||
.filter((field) => field.interface)
|
||||
.map((field) => ({
|
||||
title: compileLegacyTemplate(field.uiSchema?.title || field.name, t),
|
||||
dataIndex: field.name,
|
||||
key: field.name,
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value: unknown) => {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
}));
|
||||
}, [fields, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteServerName || !remoteTableName || !fields.length) {
|
||||
setDataSource([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
const loadPreview = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await ctx.api.resource('databaseServers.tables', remoteServerName).query({
|
||||
filterByTk: remoteTableName,
|
||||
fieldTypes: JSON.parse(fieldTypesKey) as Record<string, string>,
|
||||
});
|
||||
if (!ignore) {
|
||||
setDataSource(normalizeArrayResponse(response) as Array<Record<string, unknown>>);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
setDataSource([]);
|
||||
showError('Preview', error);
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPreview();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [ctx.api, fieldTypesKey, fields.length, remoteServerName, remoteTableName, showError]);
|
||||
|
||||
return (
|
||||
<Form.Item label={t('Preview')}>
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
rowKey={(_, index) => String(index ?? 0)}
|
||||
scroll={{ x: 1000, y: 300 }}
|
||||
/>
|
||||
</Spin>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeFdwCollectionSubmitValues(values: Record<string, unknown>) {
|
||||
const submitValues = { ...values };
|
||||
delete submitValues[internalUnsupportedFieldsName];
|
||||
submitValues.remoteTableInfo = normalizeRemoteTableValue(
|
||||
typeof submitValues.remoteTableName === 'string' ? submitValues.remoteTableName : undefined,
|
||||
).remoteTableInfo;
|
||||
return submitValues;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This program is offered under a commercial license.
|
||||
* For more information, see <https://www.nocobase.com/agreement>
|
||||
*/
|
||||
|
||||
export { default } from './plugin';
|
||||
export * from './plugin';
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This program is offered under a commercial license.
|
||||
* For more information, see <https://www.nocobase.com/agreement>
|
||||
*/
|
||||
|
||||
import { tExpr as flowTExpr, useFlowEngine } from '@nocobase/flow-engine';
|
||||
|
||||
export const NAMESPACE = 'collection-fdw';
|
||||
|
||||
export function useT() {
|
||||
const engine = useFlowEngine();
|
||||
return (key: string, options?: Record<string, any>) =>
|
||||
engine.context.t(key, { ns: [NAMESPACE, 'client'], ...options });
|
||||
}
|
||||
|
||||
export function tExpr(key: string) {
|
||||
return flowTExpr(key, { ns: [NAMESPACE, 'client'] });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This program is offered under a commercial license.
|
||||
* For more information, see <https://www.nocobase.com/agreement>
|
||||
*/
|
||||
|
||||
import { Application, Plugin } from '@nocobase/client-v2';
|
||||
import type { PluginDataSourceManagerClientV2 } from '@nocobase/plugin-data-source-manager/client-v2';
|
||||
import {
|
||||
FdwFieldsConfigureItem,
|
||||
FdwPreviewConfigureItem,
|
||||
FdwRemoteServerConfigureItem,
|
||||
FdwRemoteTableConfigureItem,
|
||||
normalizeFdwCollectionSubmitValues,
|
||||
} from './FdwCollectionConfigure';
|
||||
import { tExpr } from './locale';
|
||||
|
||||
export class PluginCollectionFDWClientV2 extends Plugin<any, Application> {
|
||||
async load() {
|
||||
const dataSourceManager = (this.app.pm.get('@nocobase/plugin-data-source-manager') ||
|
||||
this.app.pm.get('data-source-manager')) as PluginDataSourceManagerClientV2 | undefined;
|
||||
|
||||
dataSourceManager?.registerCollectionTemplate?.({
|
||||
name: 'foreign',
|
||||
title: tExpr('Connect to foreign data'),
|
||||
order: 70,
|
||||
color: 'yellow',
|
||||
collection: {
|
||||
options: {
|
||||
template: 'foreign',
|
||||
autoGenId: false,
|
||||
createdAt: false,
|
||||
createdBy: false,
|
||||
updatedAt: false,
|
||||
updatedBy: false,
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
fieldInterfaces: {
|
||||
include: ['obo', 'oho', 'o2m', 'm2o', 'm2m'],
|
||||
},
|
||||
capabilities: {
|
||||
inherits: false,
|
||||
},
|
||||
configure: {
|
||||
items: [
|
||||
{
|
||||
name: 'remoteServerName',
|
||||
Component: FdwRemoteServerConfigureItem,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'remoteTableName',
|
||||
Component: FdwRemoteTableConfigureItem,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'fields',
|
||||
Component: FdwFieldsConfigureItem,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'preview',
|
||||
Component: FdwPreviewConfigureItem,
|
||||
},
|
||||
],
|
||||
transformSubmitValues: normalizeFdwCollectionSubmitValues,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginCollectionFDWClientV2;
|
||||
+38
-17
@@ -272,6 +272,10 @@ function getSqlSyncErrorMessage(error: unknown, fallback: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function getSqlFormValue(value: unknown) {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
export function buildSqlFieldsFromPreview(options: {
|
||||
currentFields?: SqlFieldRecord[];
|
||||
manager?: FieldInterfaceManager;
|
||||
@@ -357,6 +361,7 @@ function SqlStatementControl(props: {
|
||||
return (
|
||||
<div>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 5 }}
|
||||
disabled={!editing}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
@@ -386,11 +391,31 @@ function SqlStatementControl(props: {
|
||||
export function SqlStatementConfigureItem(props: CollectionTemplateConfigureItemProps) {
|
||||
const ctx = useFlowContext();
|
||||
const t = ctx.t;
|
||||
const [confirmed, setConfirmed] = useState(() => !!props.form.getFieldValue('sql'));
|
||||
const [editing, setEditing] = useState(() => !props.form.getFieldValue('sql'));
|
||||
const initialSql = getSqlFormValue(props.form.getFieldValue('sql'));
|
||||
const [editing, setEditing] = useState(() => !initialSql);
|
||||
const sql = Form.useWatch('sql', props.form);
|
||||
const manager = ctx.dataSourceManager.collectionFieldInterfaceManager as FieldInterfaceManager;
|
||||
const autoPreviewRunRef = useRef(false);
|
||||
const confirmedSqlRef = useRef<string | undefined>(initialSql);
|
||||
const editingRef = useRef(!initialSql);
|
||||
const markEditing = useCallback(() => {
|
||||
editingRef.current = true;
|
||||
setEditing(true);
|
||||
}, []);
|
||||
const markUnconfirmed = useCallback(() => {
|
||||
confirmedSqlRef.current = undefined;
|
||||
editingRef.current = true;
|
||||
setEditing(true);
|
||||
}, []);
|
||||
const markConfirmed = useCallback(
|
||||
(value: string) => {
|
||||
confirmedSqlRef.current = value;
|
||||
editingRef.current = false;
|
||||
setEditing(false);
|
||||
props.form.setFields([{ name: 'sql', errors: [] }]);
|
||||
},
|
||||
[props.form],
|
||||
);
|
||||
const request = useRequest(
|
||||
async (statement: string) => {
|
||||
const response = await ctx.api.resource('sqlCollection').execute({
|
||||
@@ -437,14 +462,13 @@ export function SqlStatementConfigureItem(props: CollectionTemplateConfigureItem
|
||||
props.form.setFieldValue(internalPreviewName, {
|
||||
error: message || t('SQL error'),
|
||||
});
|
||||
setConfirmed(false);
|
||||
setEditing(true);
|
||||
markUnconfirmed();
|
||||
},
|
||||
[props.form, t],
|
||||
[markUnconfirmed, props.form, t],
|
||||
);
|
||||
const runSql = useCallback(
|
||||
async (options: { confirm?: boolean } = {}) => {
|
||||
const currentSql = props.form.getFieldValue('sql') || sql;
|
||||
const currentSql = getSqlFormValue(props.form.getFieldValue('sql')) || getSqlFormValue(sql);
|
||||
if (!currentSql) {
|
||||
return;
|
||||
}
|
||||
@@ -452,17 +476,16 @@ export function SqlStatementConfigureItem(props: CollectionTemplateConfigureItem
|
||||
const data = await request.runAsync(currentSql);
|
||||
applyPreviewResult(data);
|
||||
if (options.confirm) {
|
||||
setConfirmed(true);
|
||||
setEditing(false);
|
||||
markConfirmed(currentSql);
|
||||
}
|
||||
} catch (error) {
|
||||
applyPreviewError(error);
|
||||
}
|
||||
},
|
||||
[applyPreviewError, applyPreviewResult, props.form, request, sql],
|
||||
[applyPreviewError, applyPreviewResult, markConfirmed, props.form, request, sql],
|
||||
);
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!props.form.getFieldValue('sql') && !sql) {
|
||||
if (!getSqlFormValue(props.form.getFieldValue('sql')) && !getSqlFormValue(sql)) {
|
||||
return;
|
||||
}
|
||||
await runSql({ confirm: true });
|
||||
@@ -476,7 +499,7 @@ export function SqlStatementConfigureItem(props: CollectionTemplateConfigureItem
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSql = props.form.getFieldValue('sql') || sql;
|
||||
const currentSql = getSqlFormValue(props.form.getFieldValue('sql')) || getSqlFormValue(sql);
|
||||
if (!currentSql) {
|
||||
return;
|
||||
}
|
||||
@@ -496,7 +519,8 @@ export function SqlStatementConfigureItem(props: CollectionTemplateConfigureItem
|
||||
{ required: true },
|
||||
{
|
||||
validator() {
|
||||
if (confirmed && !editing) {
|
||||
const currentSql = getSqlFormValue(props.form.getFieldValue('sql')) || getSqlFormValue(sql);
|
||||
if (currentSql && confirmedSqlRef.current === currentSql && !editingRef.current) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('Please confirm the SQL statement first')));
|
||||
@@ -508,12 +532,9 @@ export function SqlStatementConfigureItem(props: CollectionTemplateConfigureItem
|
||||
editing={editing}
|
||||
loading={request.loading}
|
||||
t={t}
|
||||
onValueChange={() => {
|
||||
setConfirmed(false);
|
||||
setEditing(true);
|
||||
}}
|
||||
onValueChange={markUnconfirmed}
|
||||
onConfirm={handleConfirm}
|
||||
onEdit={() => setEditing(true)}
|
||||
onEdit={markEditing}
|
||||
onExecute={handleExecute}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -28,7 +28,6 @@ export class PluginCollectionSqlClientV2 extends Plugin<any, Application> {
|
||||
title: '{{t("SQL collection")}}',
|
||||
order: 40,
|
||||
color: 'yellow',
|
||||
divider: true,
|
||||
collection: {
|
||||
options: {
|
||||
template: 'sql',
|
||||
|
||||
@@ -90,7 +90,7 @@ export class PluginCollectionTreeClientV2 extends Plugin<any, Application> {
|
||||
dataSourceManager?.registerCollectionTemplate?.({
|
||||
name: 'tree',
|
||||
title: '{{t("Tree collection")}}',
|
||||
order: 30,
|
||||
order: 24,
|
||||
color: 'blue',
|
||||
collection: {
|
||||
options: {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dist/client-v2';
|
||||
export { default } from './dist/client-v2';
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client-v2/index.js');
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "2.x",
|
||||
"@nocobase/client-v2": "2.x",
|
||||
"@nocobase/flow-engine": "2.x",
|
||||
"@nocobase/plugin-data-source-manager": "2.x",
|
||||
"@nocobase/server": "2.x",
|
||||
"@nocobase/test": "2.x"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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';
|
||||
export * from './plugin';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 { tExpr as flowTExpr, useFlowEngine } from '@nocobase/flow-engine';
|
||||
|
||||
export const NAMESPACE = 'comments';
|
||||
|
||||
export function useT() {
|
||||
const engine = useFlowEngine();
|
||||
return (key: string, options?: Record<string, any>) =>
|
||||
engine.context.t(key, { ns: [NAMESPACE, 'client'], ...options });
|
||||
}
|
||||
|
||||
export function tExpr(key: string) {
|
||||
return flowTExpr(key, { ns: [NAMESPACE, 'client'] });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 { SettingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AddSubModelButton,
|
||||
DndProvider,
|
||||
DragHandler,
|
||||
Droppable,
|
||||
FlowModelRenderer,
|
||||
FlowSettingsButton,
|
||||
observer,
|
||||
useFlowModel,
|
||||
} from '@nocobase/flow-engine';
|
||||
import { Space } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export const CommentActions = observer(() => {
|
||||
const model = useFlowModel();
|
||||
const record = model.context.record;
|
||||
|
||||
return (
|
||||
<DndProvider>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Space size={0} style={{ gap: 0 }}>
|
||||
{model.mapSubModels('actions', (action, index) => {
|
||||
const fork = action.createFork({}, `${record.id}_${index}`);
|
||||
fork.context.defineProperty('record', {
|
||||
get: () => record,
|
||||
});
|
||||
fork.context.defineMethod('setEditing', () => {
|
||||
model.context.setEditing();
|
||||
});
|
||||
return (
|
||||
<Droppable model={fork} key={fork.uid}>
|
||||
<FlowModelRenderer
|
||||
model={fork}
|
||||
showFlowSettings={{ showBackground: false, showBorder: false }}
|
||||
extraToolbarItems={[
|
||||
{
|
||||
key: 'drag-handler',
|
||||
component: DragHandler,
|
||||
sort: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Droppable>
|
||||
);
|
||||
})}
|
||||
|
||||
{model.context.flowSettingsEnabled && (
|
||||
<AddSubModelButton
|
||||
key="comment-actions-add"
|
||||
model={model}
|
||||
subModelKey="actions"
|
||||
subModelBaseClasses={['CommentActionGroupModel']}
|
||||
>
|
||||
<FlowSettingsButton icon={<SettingOutlined />}>{model.translate('Actions')}</FlowSettingsButton>
|
||||
</AddSubModelButton>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { observer, type MultiRecordResource } from '@nocobase/flow-engine';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import { App, Button, Card, Tooltip } from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useT } from '../locale';
|
||||
import { CommentActions } from './CommentActions';
|
||||
import { getErrorMessage } from './utils';
|
||||
|
||||
type CommentRecord = {
|
||||
id: string | number;
|
||||
content?: string;
|
||||
createdAt?: string;
|
||||
createdBy?: {
|
||||
nickname?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type MarkdownRuntime = {
|
||||
edit: (props: Record<string, unknown>) => React.ReactNode;
|
||||
render: (value: string, options: Record<string, unknown>) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LiquidRuntime = {
|
||||
renderWithFullContext: (value: string, context: unknown) => Promise<string>;
|
||||
};
|
||||
|
||||
type CommentItemProps = {
|
||||
value: CommentRecord;
|
||||
resource: MultiRecordResource;
|
||||
model: {
|
||||
context: {
|
||||
markdown?: MarkdownRuntime;
|
||||
liquid?: LiquidRuntime;
|
||||
defineMethod: (name: string, method: (...args: unknown[]) => unknown) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const itemContainerStyle: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
const timelineStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
content: '',
|
||||
display: 'block',
|
||||
width: 2,
|
||||
left: 16,
|
||||
backgroundColor: '#d0d7deb3',
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
color: '#636c76',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 16,
|
||||
borderRadius: '8px 8px 0 0',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
lineHeight: '42px',
|
||||
};
|
||||
|
||||
const titleLeftStyle: React.CSSProperties = {
|
||||
backgroundColor: '#f6f8fa',
|
||||
color: '#636c76',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: 6,
|
||||
};
|
||||
|
||||
const titleRightStyle: React.CSSProperties = {
|
||||
marginRight: 16,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const editorStyle: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
};
|
||||
|
||||
const editorButtonAreaStyle: React.CSSProperties = {
|
||||
marginTop: 10,
|
||||
display: 'flex',
|
||||
columnGap: 5,
|
||||
};
|
||||
|
||||
const Display = ({
|
||||
value,
|
||||
markdown,
|
||||
liquid,
|
||||
translate,
|
||||
context,
|
||||
}: {
|
||||
value?: string;
|
||||
markdown?: MarkdownRuntime;
|
||||
liquid?: LiquidRuntime;
|
||||
translate: (key: string) => string;
|
||||
context: unknown;
|
||||
}) => {
|
||||
const [content, setContent] = useState<React.ReactNode>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function renderContent() {
|
||||
if (!value) {
|
||||
setContent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = liquid ? await liquid.renderWithFullContext(value, context) : value;
|
||||
const rendered = markdown?.render
|
||||
? markdown.render(translate(result), { ellipsis: false, textOnly: false })
|
||||
: result;
|
||||
if (active) {
|
||||
setContent(rendered);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setContent(<pre style={{ color: 'red' }}>{getErrorMessage(error, translate('Render error'))}</pre>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderContent();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [context, liquid, markdown, translate, value]);
|
||||
|
||||
return <>{content}</>;
|
||||
};
|
||||
|
||||
export const CommentItem = observer((props: CommentItemProps) => {
|
||||
const { value, resource, model } = props;
|
||||
const t = useT();
|
||||
const { message } = App.useApp();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [updateValue, setUpdateValue] = useState(value?.content || '');
|
||||
const markdown = model.context.markdown;
|
||||
|
||||
useEffect(() => {
|
||||
setUpdateValue(value?.content || '');
|
||||
}, [value?.content]);
|
||||
|
||||
model.context.defineMethod('setEditing', () => {
|
||||
setEditing(true);
|
||||
});
|
||||
|
||||
const saveComment = useCallback(async () => {
|
||||
try {
|
||||
await resource.update(value.id, {
|
||||
content: updateValue,
|
||||
});
|
||||
|
||||
await resource.refresh();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, t('Failed to update comment')));
|
||||
}
|
||||
}, [message, resource, t, updateValue, value.id]);
|
||||
|
||||
return (
|
||||
<div key={value.id}>
|
||||
<div style={itemContainerStyle}>
|
||||
<div style={timelineStyle} />
|
||||
<Card
|
||||
size="small"
|
||||
styles={{
|
||||
header: {
|
||||
padding: 0,
|
||||
fontWeight: 'normal',
|
||||
backgroundColor: '#f6f8fa',
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<div style={titleStyle}>
|
||||
<div style={titleLeftStyle}>
|
||||
<span style={{ fontWeight: 'bold', fontSize: 14 }}>{value?.createdBy?.nickname}</span>
|
||||
<span style={{ fontSize: 14 }}>{t('commented')}</span>
|
||||
<Tooltip title={dayjs(value?.createdAt).format('YYYY-MM-DD HH:mm:ss')}>
|
||||
<span style={{ fontSize: 14 }}>{dayjs(value?.createdAt).fromNow()}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div style={titleRightStyle}>
|
||||
<CommentActions />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={editorStyle}>
|
||||
{editing && markdown?.edit ? (
|
||||
markdown.edit({
|
||||
value: updateValue,
|
||||
onChange: (nextValue: string) => {
|
||||
setUpdateValue(nextValue);
|
||||
},
|
||||
enableContextSelect: false,
|
||||
})
|
||||
) : (
|
||||
<Display
|
||||
value={value?.content}
|
||||
markdown={model.context.markdown}
|
||||
liquid={model.context.liquid}
|
||||
translate={t}
|
||||
context={model.context}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<div style={editorButtonAreaStyle}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
saveComment();
|
||||
}}
|
||||
>
|
||||
{t('Update Comment')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 { FlowModelRenderer, observer, useFlowModel, type MultiRecordResource } from '@nocobase/flow-engine';
|
||||
import { List } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { CommentSubmit } from './CommentSubmit';
|
||||
|
||||
type CommentRecord = {
|
||||
id: string | number;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type CommentListProps = {
|
||||
resource: MultiRecordResource;
|
||||
handlePageChange: (page: number) => void;
|
||||
dataSource?: CommentRecord[];
|
||||
};
|
||||
|
||||
export const CommentList = observer((props: CommentListProps) => {
|
||||
const { resource, handlePageChange, dataSource } = props;
|
||||
const [quoteContent, setQuoteContent] = useState('');
|
||||
const model = useFlowModel();
|
||||
|
||||
model.context.defineMethod('setQuoteContent', (value: string) => {
|
||||
setQuoteContent(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<List
|
||||
pagination={
|
||||
resource?.getMeta('count') > 0
|
||||
? {
|
||||
onChange: handlePageChange,
|
||||
total: resource?.getMeta('count') || 0,
|
||||
pageSize: resource.getPageSize() || 10,
|
||||
current: resource.getPage() || 1,
|
||||
}
|
||||
: false
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{dataSource?.length
|
||||
? dataSource.map((item, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === dataSource.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: `${isFirst ? 0 : '10px'} 0 ${isLast ? 0 : '10px'} 0`,
|
||||
}}
|
||||
>
|
||||
{model.mapSubModels('items', (itemModel) => {
|
||||
const fork = itemModel.createFork({}, `${item.id}-${item.content || ''}`);
|
||||
fork.context.defineProperty('record', {
|
||||
get: () => item,
|
||||
cache: false,
|
||||
});
|
||||
return <FlowModelRenderer key={fork.uid} model={fork} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</List>
|
||||
<CommentSubmit defaultValue={quoteContent} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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 { useFlowModel, type MultiRecordResource, observer } from '@nocobase/flow-engine';
|
||||
import { App, Button, Input } from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useT } from '../locale';
|
||||
import { getErrorMessage } from './utils';
|
||||
|
||||
type MarkdownEditor = {
|
||||
edit: (props: Record<string, unknown>) => React.ReactNode;
|
||||
};
|
||||
|
||||
type CommentSubmitProps = {
|
||||
createAble?: boolean;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type CommentModel = ReturnType<typeof useFlowModel> & {
|
||||
collection?: {
|
||||
getFields: () => { name: string; uiSchema?: Record<string, unknown> }[];
|
||||
};
|
||||
context: {
|
||||
markdown?: MarkdownEditor;
|
||||
};
|
||||
resource: MultiRecordResource;
|
||||
};
|
||||
|
||||
export const CommentSubmit = observer((props: CommentSubmitProps) => {
|
||||
const { createAble = true, defaultValue = '' } = props;
|
||||
const model = useFlowModel() as CommentModel;
|
||||
const markdown = model.context.markdown;
|
||||
const resource = model.resource;
|
||||
const t = useT();
|
||||
const { message } = App.useApp();
|
||||
const [content, setContent] = useState(defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
const canSubmit = useMemo(() => content.trim().length > 0, [content]);
|
||||
|
||||
const contentFieldComponentProps = useMemo(() => {
|
||||
return model.collection?.getFields().find((field) => field.name === 'content')?.uiSchema?.['x-component-props'];
|
||||
}, [model.collection]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
try {
|
||||
await resource.create({
|
||||
content,
|
||||
});
|
||||
|
||||
setContent('');
|
||||
const total = resource.getMeta('count') || 0;
|
||||
const pageSize = resource.getPageSize() || 10;
|
||||
resource.setPage(Math.max(Math.ceil((total + 1) / pageSize), 1));
|
||||
await resource.refresh();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, t('Failed to create comment')));
|
||||
}
|
||||
}, [content, message, resource, t]);
|
||||
|
||||
if (!createAble) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{markdown?.edit ? (
|
||||
markdown.edit({
|
||||
...(typeof contentFieldComponentProps === 'object' ? contentFieldComponentProps : {}),
|
||||
value: content,
|
||||
quoteFlag: true,
|
||||
onChange: (value: string) => {
|
||||
setContent(value);
|
||||
},
|
||||
enableContextSelect: false,
|
||||
})
|
||||
) : (
|
||||
<Input.TextArea value={content} onChange={(event) => setContent(event.target.value)} autoSize />
|
||||
)}
|
||||
<Button disabled={!canSubmit} onClick={submit} type="primary" style={{ marginTop: 10 }}>
|
||||
{t('Comment')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 { BlockSceneEnum, CollectionBlockModel } from '@nocobase/client-v2';
|
||||
import { FlowModel, MultiRecordResource, type Collection } from '@nocobase/flow-engine';
|
||||
import { Alert } from 'antd';
|
||||
import React from 'react';
|
||||
import { tExpr } from '../locale';
|
||||
import { CommentItem } from './CommentItem';
|
||||
import { CommentList } from './CommentList';
|
||||
|
||||
type CommentsBlockStructure = {
|
||||
subModels: {
|
||||
items: CommentItemModel[];
|
||||
};
|
||||
};
|
||||
|
||||
export class CommentItemModel extends FlowModel {
|
||||
render() {
|
||||
return <CommentItem value={this.context.record} resource={this.context.blockModel.resource} model={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentsBlockModel extends CollectionBlockModel<CommentsBlockStructure> {
|
||||
static scene = BlockSceneEnum.oam;
|
||||
|
||||
static filterCollection(collection: Collection) {
|
||||
return collection.template === 'comment';
|
||||
}
|
||||
|
||||
get resource() {
|
||||
return super.resource as MultiRecordResource;
|
||||
}
|
||||
|
||||
createResource() {
|
||||
const resource = this.context.createResource(MultiRecordResource);
|
||||
resource.setPageSize(this.props.pageSize || 20);
|
||||
resource.addAppends('createdBy');
|
||||
return resource;
|
||||
}
|
||||
|
||||
async handlePageChange(page: number) {
|
||||
this.resource.setPage(page);
|
||||
this.resource.loading = true;
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
renderComponent() {
|
||||
const dataSource = this.resource.getData();
|
||||
|
||||
if (this.collection.template !== 'comment') {
|
||||
return (
|
||||
<Alert
|
||||
message={this.context.t(
|
||||
'The current collection is not a comment collection, so the comment block cannot be used.',
|
||||
{ ns: 'comments' },
|
||||
)}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CommentList
|
||||
dataSource={Array.isArray(dataSource) ? dataSource : []}
|
||||
resource={this.resource}
|
||||
handlePageChange={(page) => {
|
||||
this.handlePageChange(page);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentsBlockModel.registerFlow({
|
||||
key: 'commentsSettings',
|
||||
title: tExpr('Comments settings'),
|
||||
on: 'beforeRender',
|
||||
sort: 150,
|
||||
steps: {
|
||||
pageSize: {
|
||||
title: tExpr('Page size'),
|
||||
uiSchema: {
|
||||
pageSize: {
|
||||
'x-component': 'Select',
|
||||
'x-decorator': 'FormItem',
|
||||
enum: [
|
||||
{ label: '5', value: 5 },
|
||||
{ label: '10', value: 10 },
|
||||
{ label: '20', value: 20 },
|
||||
{ label: '50', value: 50 },
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '200', value: 200 },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultParams: {
|
||||
pageSize: 20,
|
||||
},
|
||||
handler(ctx, params) {
|
||||
ctx.model.props.pageSize = params.pageSize;
|
||||
ctx.model.resource.loading = true;
|
||||
ctx.model.resource.setPage(1);
|
||||
ctx.model.resource.setPageSize(params.pageSize);
|
||||
},
|
||||
},
|
||||
dataScope: {
|
||||
use: 'dataScope',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
CommentsBlockModel.define({
|
||||
label: tExpr('Comments'),
|
||||
searchable: true,
|
||||
searchPlaceholder: tExpr('Search'),
|
||||
createModelOptions: {
|
||||
use: 'CommentsBlockModel',
|
||||
subModels: {
|
||||
items: [
|
||||
{
|
||||
use: 'CommentItemModel',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: 550,
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 { ActionGroupModel, ActionModel } from '@nocobase/client-v2';
|
||||
import { tExpr } from '../../locale';
|
||||
|
||||
export class CommentActionModel extends ActionModel {}
|
||||
|
||||
export class CommentActionGroupModel extends ActionGroupModel {
|
||||
static baseClass = CommentActionModel;
|
||||
}
|
||||
|
||||
CommentActionGroupModel.define({
|
||||
label: tExpr('Comment action'),
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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 { ActionSceneEnum } from '@nocobase/client-v2';
|
||||
import type { ButtonProps } from 'antd/es/button';
|
||||
import { tExpr } from '../../locale';
|
||||
import { getErrorMessage } from '../utils';
|
||||
import { CommentActionModel } from './CommentActionGroupModel';
|
||||
|
||||
export class DeleteCommentActionModel extends CommentActionModel {
|
||||
static scene = ActionSceneEnum.record;
|
||||
|
||||
defaultProps: ButtonProps = {
|
||||
type: 'link',
|
||||
title: tExpr('Delete'),
|
||||
};
|
||||
|
||||
getAclActionName() {
|
||||
return 'destroy';
|
||||
}
|
||||
}
|
||||
|
||||
DeleteCommentActionModel.define({
|
||||
label: tExpr('Delete'),
|
||||
toggleable: true,
|
||||
});
|
||||
|
||||
DeleteCommentActionModel.registerFlow({
|
||||
key: 'deleteSettings',
|
||||
title: tExpr('Delete settings'),
|
||||
on: 'click',
|
||||
steps: {
|
||||
confirm: {
|
||||
use: 'confirm',
|
||||
defaultParams: {
|
||||
enable: true,
|
||||
title: tExpr('Delete record'),
|
||||
content: tExpr('Are you sure you want to delete it?'),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
async handler(ctx) {
|
||||
try {
|
||||
if (!ctx.resource || !ctx.record || !ctx.collection) {
|
||||
ctx.message.error(ctx.t('No resource or record selected for deletion'));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.resource.destroy(ctx.record[ctx.collection.filterTargetKey || 'id']);
|
||||
await ctx.resource.refresh();
|
||||
ctx.message.success(ctx.t('Record deleted successfully'));
|
||||
} catch (error) {
|
||||
ctx.message.error(getErrorMessage(error, ctx.t('Failed to delete comment')));
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { ActionSceneEnum } from '@nocobase/client-v2';
|
||||
import type { ButtonProps } from 'antd/es/button';
|
||||
import { tExpr } from '../../locale';
|
||||
import { CommentActionModel } from './CommentActionGroupModel';
|
||||
|
||||
export class EditCommentActionModel extends CommentActionModel {
|
||||
static scene = ActionSceneEnum.record;
|
||||
|
||||
defaultProps: ButtonProps = {
|
||||
type: 'link',
|
||||
title: tExpr('Edit'),
|
||||
};
|
||||
|
||||
getAclActionName() {
|
||||
return 'update';
|
||||
}
|
||||
}
|
||||
|
||||
EditCommentActionModel.define({
|
||||
label: tExpr('Edit'),
|
||||
toggleable: true,
|
||||
});
|
||||
|
||||
EditCommentActionModel.registerFlow({
|
||||
key: 'editCommentSettings',
|
||||
on: 'click',
|
||||
steps: {
|
||||
edit: {
|
||||
async handler(ctx) {
|
||||
ctx.setEditing();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+49
@@ -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 { ActionSceneEnum } from '@nocobase/client-v2';
|
||||
import type { ButtonProps } from 'antd/es/button';
|
||||
import { tExpr } from '../../locale';
|
||||
import { CommentActionModel } from './CommentActionGroupModel';
|
||||
|
||||
export class QuoteReplyActionModel extends CommentActionModel {
|
||||
static scene = ActionSceneEnum.record;
|
||||
|
||||
defaultProps: ButtonProps = {
|
||||
type: 'link',
|
||||
title: tExpr('Quote reply'),
|
||||
};
|
||||
|
||||
getAclActionName() {
|
||||
return 'create';
|
||||
}
|
||||
}
|
||||
|
||||
QuoteReplyActionModel.define({
|
||||
label: tExpr('Quote reply'),
|
||||
toggleable: true,
|
||||
});
|
||||
|
||||
QuoteReplyActionModel.registerFlow({
|
||||
key: 'quoteReplySettings',
|
||||
on: 'click',
|
||||
steps: {
|
||||
quoteReply: {
|
||||
async handler(ctx) {
|
||||
const blockModel = ctx.model.context.blockModel;
|
||||
const content = ctx.record?.content ?? '';
|
||||
const quoteContent = `${content
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n')}`;
|
||||
blockModel.context.setQuoteContent(quoteContent);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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 './CommentActionGroupModel';
|
||||
export * from './DeleteCommentActionModel';
|
||||
export * from './EditCommentActionModel';
|
||||
export * from './QuoteReplyActionModel';
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
type ErrorLike = {
|
||||
message?: string;
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
errors?: { message?: string }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const errorLike = error as ErrorLike;
|
||||
return (
|
||||
errorLike.response?.data?.errors?.[0]?.message || errorLike.response?.data?.message || errorLike.message || fallback
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 { Application, Plugin } from '@nocobase/client-v2';
|
||||
import type { PluginDataSourceManagerClientV2 } from '@nocobase/plugin-data-source-manager/client-v2';
|
||||
import { tExpr } from './locale';
|
||||
|
||||
export class PluginCommentClientV2 extends Plugin<any, Application> {
|
||||
async load() {
|
||||
this.flowEngine.registerModelLoaders({
|
||||
CommentsBlockModel: {
|
||||
loader: () => import('./models/CommentsBlockModel'),
|
||||
},
|
||||
CommentItemModel: {
|
||||
loader: () => import('./models/CommentsBlockModel'),
|
||||
},
|
||||
CommentActionGroupModel: {
|
||||
loader: () => import('./models/actions'),
|
||||
},
|
||||
EditCommentActionModel: {
|
||||
loader: () => import('./models/actions'),
|
||||
},
|
||||
DeleteCommentActionModel: {
|
||||
loader: () => import('./models/actions'),
|
||||
},
|
||||
QuoteReplyActionModel: {
|
||||
loader: () => import('./models/actions'),
|
||||
},
|
||||
});
|
||||
|
||||
const dataSourceManager = (this.app.pm.get('@nocobase/plugin-data-source-manager') ||
|
||||
this.app.pm.get('data-source-manager')) as PluginDataSourceManagerClientV2 | undefined;
|
||||
|
||||
dataSourceManager?.registerCollectionTemplate?.({
|
||||
name: 'comment',
|
||||
title: tExpr('Comment Collection'),
|
||||
order: 22,
|
||||
color: 'orange',
|
||||
collection: {
|
||||
options: {
|
||||
template: 'comment',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
length: 'long',
|
||||
interface: 'vditor',
|
||||
deletable: false,
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: tExpr('Comment Content'),
|
||||
interface: 'vditor',
|
||||
'x-component': 'MarkdownVditor',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
presetFields: {
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginCommentClientV2;
|
||||
@@ -8,8 +8,12 @@
|
||||
"Delete": "Delete",
|
||||
"Edit": "Edit",
|
||||
"Enable Create": "Allow adding comments",
|
||||
"Failed to create comment": "Failed to create comment",
|
||||
"Failed to delete comment": "Failed to delete comment",
|
||||
"Failed to update comment": "Failed to update comment",
|
||||
"Quote Reply": "Quote reply",
|
||||
"Quote reply": "Quote reply",
|
||||
"Render error": "Render error",
|
||||
"Update Comment": "Update comment",
|
||||
"commented": "commented"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
"Delete": "删除",
|
||||
"Edit": "编辑",
|
||||
"Enable Create": "允许增加评论",
|
||||
"Failed to create comment": "创建评论失败",
|
||||
"Failed to delete comment": "删除评论失败",
|
||||
"Failed to update comment": "更新评论失败",
|
||||
"Quote Reply": "引用并回复",
|
||||
"Quote reply": "引用并回复",
|
||||
"Render error": "渲染错误",
|
||||
"Update Comment": "更新评论",
|
||||
"commented": "评论于",
|
||||
"The current collection is not a comment collection, so the comment block cannot be used.": "当前表不是评论表,无法使用评论区块。",
|
||||
|
||||
+31
-17
@@ -631,11 +631,15 @@ function getTemplatePresetFieldsDisabledIncludes(template: CollectionTemplateOpt
|
||||
|
||||
function hasTemplateCapability(
|
||||
template: CollectionTemplateOptions | undefined,
|
||||
capability: 'recordUniqueKey' | 'simplePaginate',
|
||||
capability: keyof NonNullable<CollectionTemplateOptions['capabilities']>,
|
||||
) {
|
||||
return !!template?.capabilities?.[capability];
|
||||
}
|
||||
|
||||
function supportsTemplateInherits(template: CollectionTemplateOptions | undefined) {
|
||||
return template?.capabilities?.inherits !== false;
|
||||
}
|
||||
|
||||
const CollectionTemplatePreview: FC<{ template?: CollectionTemplateOptions }> = ({ template }) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
@@ -911,6 +915,7 @@ function CollectionCreateDrawer(props: {
|
||||
[t],
|
||||
);
|
||||
const TemplateConfigureForm = template.configure?.Form || template.ConfigureForm;
|
||||
const supportsInherits = supportsTemplateInherits(template);
|
||||
const collectionCategoryFormItem = (
|
||||
<Form.Item name="category" label={t('Categories')}>
|
||||
<Select
|
||||
@@ -928,6 +933,12 @@ function CollectionCreateDrawer(props: {
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
|
||||
</Form.Item>
|
||||
);
|
||||
const templateConfigureItems = (
|
||||
<>
|
||||
{TemplateConfigureForm ? <TemplateConfigureForm mode="create" template={template} form={form} /> : null}
|
||||
<CollectionTemplateConfigureItems mode="create" template={template} form={form} />
|
||||
</>
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
@@ -1002,10 +1013,9 @@ function CollectionCreateDrawer(props: {
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{templateConfigureItems}
|
||||
{isSqlTemplate ? (
|
||||
<>
|
||||
{TemplateConfigureForm ? <TemplateConfigureForm mode="create" template={template} form={form} /> : null}
|
||||
<CollectionTemplateConfigureItems mode="create" template={template} form={form} />
|
||||
{hasTemplateCapability(template, 'recordUniqueKey') ? (
|
||||
<CollectionCreateFilterTargetKey form={form} />
|
||||
) : null}
|
||||
@@ -1014,8 +1024,6 @@ function CollectionCreateDrawer(props: {
|
||||
</>
|
||||
) : isViewTemplate ? (
|
||||
<>
|
||||
{TemplateConfigureForm ? <TemplateConfigureForm mode="create" template={template} form={form} /> : null}
|
||||
<CollectionTemplateConfigureItems mode="create" template={template} form={form} />
|
||||
{hasTemplateCapability(template, 'recordUniqueKey') ? (
|
||||
<CollectionCreateFilterTargetKey form={form} />
|
||||
) : null}
|
||||
@@ -1033,9 +1041,11 @@ function CollectionCreateDrawer(props: {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="inherits" label={t('Inherits')}>
|
||||
<Select mode="multiple" options={collectionOptions} loading={collectionRequest.loading} allowClear />
|
||||
</Form.Item>
|
||||
{supportsInherits ? (
|
||||
<Form.Item name="inherits" label={t('Inherits')}>
|
||||
<Select mode="multiple" options={collectionOptions} loading={collectionRequest.loading} allowClear />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{collectionCategoryFormItem}
|
||||
{collectionDescriptionFormItem}
|
||||
<Form.Item
|
||||
@@ -1047,8 +1057,6 @@ function CollectionCreateDrawer(props: {
|
||||
>
|
||||
<Checkbox>{t('Use simple pagination mode')}</Checkbox>
|
||||
</Form.Item>
|
||||
{TemplateConfigureForm ? <TemplateConfigureForm mode="create" template={template} form={form} /> : null}
|
||||
<CollectionTemplateConfigureItems mode="create" template={template} form={form} />
|
||||
{hasTemplateCapability(template, 'recordUniqueKey') ? (
|
||||
<CollectionCreateFilterTargetKey form={form} />
|
||||
) : null}
|
||||
@@ -1280,6 +1288,13 @@ function CollectionEditDrawer(props: {
|
||||
}
|
||||
|
||||
const TemplateConfigureForm = template?.configure?.Form || template?.ConfigureForm;
|
||||
const supportsInherits = supportsTemplateInherits(template);
|
||||
const templateConfigureItems = template ? (
|
||||
<>
|
||||
{TemplateConfigureForm ? <TemplateConfigureForm mode="edit" template={template} form={form} /> : null}
|
||||
<CollectionTemplateConfigureItems mode="edit" template={template} form={form} />
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DrawerFormLayout
|
||||
@@ -1303,11 +1318,14 @@ function CollectionEditDrawer(props: {
|
||||
>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
{templateConfigureItems}
|
||||
{isMainDataSource ? (
|
||||
<>
|
||||
<Form.Item name="inherits" label={t('Inherits')}>
|
||||
<Select mode="multiple" options={collectionOptions} loading={collectionRequest.loading} allowClear />
|
||||
</Form.Item>
|
||||
{supportsInherits ? (
|
||||
<Form.Item name="inherits" label={t('Inherits')}>
|
||||
<Select mode="multiple" options={collectionOptions} loading={collectionRequest.loading} allowClear />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="category" label={t('Categories')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
@@ -1343,10 +1361,6 @@ function CollectionEditDrawer(props: {
|
||||
<Select mode="multiple" options={filterTargetKeyOptions} loading={fieldsRequest.loading} allowClear />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{template && TemplateConfigureForm ? (
|
||||
<TemplateConfigureForm mode="edit" template={template} form={form} />
|
||||
) : null}
|
||||
{template ? <CollectionTemplateConfigureItems mode="edit" template={template} form={form} /> : null}
|
||||
</Form>
|
||||
</DrawerFormLayout>
|
||||
);
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface CollectionTemplateOptions {
|
||||
fields?: CollectionTemplateField[] | (() => CollectionTemplateField[]);
|
||||
};
|
||||
capabilities?: {
|
||||
inherits?: boolean;
|
||||
recordUniqueKey?: boolean;
|
||||
simplePaginate?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dist/client-v2';
|
||||
export { default } from './dist/client-v2';
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client-v2/index.js');
|
||||
@@ -25,8 +25,11 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "2.x",
|
||||
"@nocobase/client-v2": "2.x",
|
||||
"@nocobase/database": "2.x",
|
||||
"@nocobase/evaluators": "2.x",
|
||||
"@nocobase/flow-engine": "2.x",
|
||||
"@nocobase/plugin-data-source-manager": "2.x",
|
||||
"@nocobase/plugin-data-source-main": "2.x",
|
||||
"@nocobase/plugin-workflow": ">=0.17.0-alpha.3",
|
||||
"@nocobase/server": "2.x",
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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';
|
||||
export * from './plugin';
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 { tExpr as flowTExpr, useFlowEngine } from '@nocobase/flow-engine';
|
||||
|
||||
export const NAMESPACE = 'workflow-dynamic-calculation';
|
||||
|
||||
export function useT() {
|
||||
const engine = useFlowEngine();
|
||||
return (key: string, options?: Record<string, any>) =>
|
||||
engine.context.t(key, { ns: [NAMESPACE, 'client'], ...options });
|
||||
}
|
||||
|
||||
export function tExpr(key: string) {
|
||||
return flowTExpr(key, { ns: [NAMESPACE, 'client'] });
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 { getOptions } from '@nocobase/evaluators/client';
|
||||
import { Application, Plugin } from '@nocobase/client-v2';
|
||||
import type { PluginDataSourceManagerClientV2 } from '@nocobase/plugin-data-source-manager/client-v2';
|
||||
import { tExpr } from './locale';
|
||||
|
||||
export default class PluginWorkflowDynamicCalculationClientV2 extends Plugin<any, Application> {
|
||||
async load() {
|
||||
const dataSourceManager = (this.app.pm.get('@nocobase/plugin-data-source-manager') ||
|
||||
this.app.pm.get('data-source-manager')) as PluginDataSourceManagerClientV2 | undefined;
|
||||
|
||||
dataSourceManager?.registerCollectionTemplate?.({
|
||||
name: 'expression',
|
||||
title: tExpr('Expression collection'),
|
||||
order: 60,
|
||||
color: 'orange',
|
||||
collection: {
|
||||
options: {
|
||||
template: 'expression',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'engine',
|
||||
type: 'string',
|
||||
interface: 'radioGroup',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: tExpr('Calculation engine'),
|
||||
'x-component': 'Radio.Group',
|
||||
enum: getOptions(),
|
||||
default: 'formula.js',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sourceCollection',
|
||||
type: 'string',
|
||||
interface: 'select',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: tExpr('Collection'),
|
||||
'x-component': 'CollectionSelect',
|
||||
'x-component-props': {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'expression',
|
||||
type: 'text',
|
||||
interface: 'expression',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: tExpr('Expression'),
|
||||
'x-component': 'DynamicExpression',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
fieldInterfaces: {
|
||||
include: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user