Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-06-09 05:44:34 +00:00
37 changed files with 2246 additions and 37 deletions
@@ -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"
},
@@ -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;
@@ -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>
);
});
@@ -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,
});
@@ -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'),
});
@@ -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')));
}
},
},
},
});
@@ -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();
},
},
},
});
@@ -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.": "当前表不是评论表,无法使用评论区块。",
@@ -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",
@@ -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 = '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'] });
}
@@ -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: [],
},
});
}
}