fix(data-source-manager): respect non-deletable v2 template fields (#9940)

* fix(data-source-manager): respect non-deletable v2 template fields

* fix(data-source-manager): align v2 inherited field actions

* fix(data-source-manager): fix v2 fields declaration build
This commit is contained in:
Katherine
2026-06-30 09:26:53 +08:00
committed by GitHub
parent 5dc02d18fe
commit 208cece6f2
3 changed files with 371 additions and 18 deletions
@@ -66,6 +66,7 @@ interface FieldFormProps {
collection: Record<string, any>;
interfaceName?: string;
field?: Record<string, any>;
override?: boolean;
onSubmitted: () => void;
}
@@ -1811,9 +1812,11 @@ export function FieldForm(props: FieldFormProps) {
createMainOnly: props.mode === 'create' && props.dataSourceKey === 'main',
disabledJSONB: props.mode === 'edit',
isDialect: (dialect: string) => databaseDialect === dialect,
isOverride: !!props.override,
override: !!props.override,
primaryKeyOnly: false,
}),
[databaseDialect, props.dataSourceKey, props.mode],
[databaseDialect, props.dataSourceKey, props.mode, props.override],
);
const previousWatchedValuesRef = useRef<Record<string, any>>();
const pendingInitialValuesRef = useRef<Record<string, any>>();
@@ -2047,7 +2050,9 @@ export function FieldForm(props: FieldFormProps) {
]);
const collectionTitle = compileLegacyTemplateText(get(props.collection, 'title') || props.collection.name, t);
const title = `${collectionTitle} - ${props.mode === 'create' ? t('Add field') : t('Edit field')}`;
const title = `${collectionTitle} - ${
props.override ? t('Override field') : props.mode === 'create' ? t('Add field') : t('Edit field')
}`;
const existingFieldNames = new Set((props.collection.fields || []).map((field: Record<string, any>) => field.name));
return (
@@ -2102,7 +2107,10 @@ export function FieldForm(props: FieldFormProps) {
]}
extra={t(fieldNameDescription)}
>
<Input autoComplete="off" disabled={props.mode === 'edit' || interfaceName === 'tableoid'} />
<Input
autoComplete="off"
disabled={props.mode === 'edit' || props.override || interfaceName === 'tableoid'}
/>
</Form.Item>
<FieldConfigureItemsRenderer
items={mainConfigureItems}
@@ -16,6 +16,7 @@ import {
App,
Button,
Cascader,
Descriptions,
Dropdown,
Form,
Input,
@@ -26,6 +27,7 @@ import {
Table as AntdTable,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { MenuProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -77,6 +79,13 @@ type ViewFieldRecord = {
[key: string]: any;
};
type InheritedFieldGroup = {
collectionName: string;
fields: Record<string, any>[];
key: string;
title?: React.ReactNode;
};
type ViewFieldsResponse = {
fields?: ViewFieldRecord[] | Record<string, ViewFieldRecord>;
sources?: string[];
@@ -293,6 +302,30 @@ function EditableFieldDisplayNameCell(props: {
);
}
function InheritedFieldDetails(props: { field: Record<string, any>; fieldInterface?: FieldInterfaceOption }) {
const t = useT();
const field = props.field;
const fieldInterfaceTitle = getFieldInterfaceLabel(props.fieldInterface, field.interface);
return (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label={t('Field display name')}>
{compileLegacyTemplate(getEditableFieldDisplayName(field), t)}
</Descriptions.Item>
<Descriptions.Item label={t('Field name')}>{field.name}</Descriptions.Item>
<Descriptions.Item label={t('Field type')}>
<Tag>{field.type}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('Field interface')}>
<Tag>{compileLegacyTemplate(fieldInterfaceTitle, t)}</Tag>
</Descriptions.Item>
{field.description ? (
<Descriptions.Item label={t('Description')}>{compileLegacyTemplate(field.description, t)}</Descriptions.Item>
) : null}
</Descriptions>
);
}
function isReadOnlyFieldInterface(
record: Record<string, any>,
options: {
@@ -388,6 +421,82 @@ function isSyncFieldsVisible(dataSourceKey: string, collection: Record<string, a
return dataSourceKey === 'main';
}
function getCollectionTemplateFields(collectionTemplate?: Record<string, any>) {
const fields = collectionTemplate?.collection?.fields;
return typeof fields === 'function' ? fields() : Array.isArray(fields) ? fields : [];
}
export function isFieldDeleteDisabled(record: Record<string, any>, collectionTemplate?: Record<string, any>) {
if (record.deletable === false) {
return true;
}
return getCollectionTemplateFields(collectionTemplate).some(
(field: Record<string, any>) => field?.name === record.name && field.deletable === false,
);
}
function normalizeRuntimeField(field: any, collectionName?: string) {
const options = field?.options || field || {};
return {
...options,
collectionName: options.collectionName || field?.collectionName || field?.collection?.name || collectionName,
};
}
function getRuntimeCollectionOwnFields(collection: any) {
if (!collection) {
return [];
}
if (collection.fields instanceof Map) {
return Array.from(collection.fields.values()).map((field) => normalizeRuntimeField(field, collection.name));
}
return Array.isArray(collection.fields)
? collection.fields.map((field) => normalizeRuntimeField(field, collection.name))
: [];
}
export function getInheritedFieldGroups(collection: any) {
const groups: InheritedFieldGroup[] = [];
const visitedCollections = new Set<string>();
const visitParentCollections = (currentCollection: any) => {
const parentCollections =
currentCollection?.inherits instanceof Map ? Array.from(currentCollection.inherits.values()) : [];
parentCollections.forEach((parentCollection: any) => {
if (!parentCollection?.name || visitedCollections.has(parentCollection.name)) {
return;
}
visitedCollections.add(parentCollection.name);
groups.push({
collectionName: parentCollection.name,
fields: getRuntimeCollectionOwnFields(parentCollection),
key: parentCollection.name,
title: parentCollection.title || parentCollection.options?.title || parentCollection.name,
});
visitParentCollections(parentCollection);
});
};
visitParentCollections(collection);
return groups;
}
export function isInheritedFieldOverridden(
record: Record<string, any>,
currentCollectionName: string,
currentFieldsByName: Map<string, Record<string, any>>,
) {
const currentField = currentFieldsByName.get(record.name);
return Boolean(
currentField && (!currentField.collectionName || currentField.collectionName === currentCollectionName),
);
}
function getOverrideInitialField(record: Record<string, any>) {
const { collectionName, key, sort, uiSchemaUid, ...rest } = record;
return rest;
}
function isAddFieldVisible(options: {
collection: Record<string, any>;
dataSourceType?: string;
@@ -926,9 +1035,18 @@ export default function FieldsPage(props: FieldsPageProps) {
}, {});
}, [databaseDialect, ctx, dataSource?.options?.type, props.collection]);
const presetFieldInterfaces = useMemo(() => getCollectionPresetFieldInterfaces(ctx), [ctx]);
const collectionTemplate = useMemo(() => getCollectionTemplate(ctx, props.collection), [ctx, props.collection]);
const currentFieldsByName = useMemo(() => {
return new Map((request.data || []).map((field: Record<string, any>) => [String(field.name), field]));
}, [request.data]);
const openFieldForm = useCallback(
(mode: 'create' | 'edit', field?: Record<string, any>, interfaceName?: string) => {
(
mode: 'create' | 'edit',
field?: Record<string, any>,
interfaceName?: string,
options?: { override?: boolean },
) => {
ctx.viewer.drawer({
width: 800,
closable: true,
@@ -939,6 +1057,7 @@ export default function FieldsPage(props: FieldsPageProps) {
collection={props.collection}
interfaceName={interfaceName}
field={field}
override={options?.override}
onSubmitted={() => request.refresh()}
/>
),
@@ -967,8 +1086,9 @@ export default function FieldsPage(props: FieldsPageProps) {
[fieldInterfaceGroups, openFieldForm, t],
);
const collectionTemplate = useMemo(() => getCollectionTemplate(ctx, props.collection), [ctx, props.collection]);
const TemplateSyncFieldsDrawer = collectionTemplate?.configure?.syncFields?.Component;
const runtimeCollection = dataSource?.collectionManager?.getCollection?.(props.collection.name);
const inheritedFieldGroups = getInheritedFieldGroups(runtimeCollection);
const openTemplateSyncFieldsDrawer = useCallback(() => {
if (!TemplateSyncFieldsDrawer) {
@@ -1007,14 +1127,45 @@ export default function FieldsPage(props: FieldsPageProps) {
});
}, [allFieldInterfaceGroups, ctx.viewer, fieldInterfacesByName, props.collection, request]);
const openInheritedFieldView = useCallback(
(record: Record<string, any>, parentTitle?: React.ReactNode) => {
const fieldInterface = record.interface ? fieldInterfacesByName[record.interface] : undefined;
ctx.viewer.drawer({
title: `${compileLegacyTemplate(parentTitle || record.collectionName || props.collection.name, t)} - ${t(
'View',
)}`,
width: 520,
closable: true,
content: () => <InheritedFieldDetails field={record} fieldInterface={fieldInterface} />,
});
},
[ctx.viewer, fieldInterfacesByName, props.collection.name, t],
);
const openInheritedFieldOverride = useCallback(
(record: Record<string, any>) => {
if (isInheritedFieldOverridden(record, props.collection.name, currentFieldsByName)) {
return;
}
openFieldForm('create', getOverrideInitialField(record), record.interface, { override: true });
},
[currentFieldsByName, openFieldForm, props.collection.name],
);
const handleDelete = useCallback(
(filterByTk: React.Key | React.Key[]) => {
const keys = (Array.isArray(filterByTk) ? filterByTk : [filterByTk]).filter((key) => {
const record = currentFieldsByName.get(String(key));
return record && !isFieldDeleteDisabled(record, collectionTemplate);
});
if (!keys.length) {
return;
}
modal.confirm({
title: t('Delete record'),
content: t('Are you sure you want to delete it?'),
async onOk() {
try {
const keys = Array.isArray(filterByTk) ? filterByTk : [filterByTk];
await Promise.all(
keys.map((key) =>
ctx.api.request({
@@ -1035,7 +1186,18 @@ export default function FieldsPage(props: FieldsPageProps) {
},
});
},
[ctx.api, ctx.dataSourceManager, modal, notification, props.collection.name, props.dataSourceKey, request, t],
[
collectionTemplate,
ctx.api,
ctx.dataSourceManager,
currentFieldsByName,
modal,
notification,
props.collection.name,
props.dataSourceKey,
request,
t,
],
);
const handleFieldInterfaceChange = useCallback(
@@ -1145,6 +1307,102 @@ export default function FieldsPage(props: FieldsPageProps) {
[ctx, message, notification, props, t],
);
const syncFieldsVisible = isSyncFieldsVisible(props.dataSourceKey, props.collection, ctx);
const configureFieldsDisabled = Boolean(dataSourceType?.disableConfigureFields);
const fieldDeletionVisible = isMainDataSource && !configureFieldsDisabled;
const addFieldVisible = isAddFieldVisible({
collection: props.collection,
ctx,
dataSourceType: dataSource?.options?.type,
fieldInterfaceGroups,
});
const inheritedFieldColumns = useMemo<ColumnsType<Record<string, any>>>(
() => [
{
title: t('Field display name'),
dataIndex: ['uiSchema', 'title'],
width: 240,
render: (_, record) => compileLegacyTemplate(getEditableFieldDisplayName(record), t),
},
{ title: t('Field name'), dataIndex: 'name' },
{ title: t('Field type'), dataIndex: 'type', render: (value) => <Tag>{value}</Tag> },
{
title: t('Field interface'),
dataIndex: 'interface',
width: 180,
render: (value) => (
<Tag>{compileLegacyTemplate(getFieldInterfaceLabel(fieldInterfacesByName[value], value), t)}</Tag>
),
},
{
title: t('Title field'),
dataIndex: 'titleField',
width: 120,
render: (_, record) =>
isTitleField(ctx.dataSourceManager, record) ? (
<Tooltip title={t('Default title for each record')} placement="right">
<Switch
aria-label={`switch-title-field-${record.name}`}
size="small"
disabled={configureFieldsDisabled}
loading={record.name === titleFieldLoadingKey}
checked={record.name === (titleField || 'id')}
onChange={(checked) => handleTitleFieldChange(record, checked)}
/>
</Tooltip>
) : null,
},
{ title: t('Description'), dataIndex: 'description', ellipsis: true },
{
title: t('Actions'),
width: 160,
render: (_, record) => {
const overridden = isInheritedFieldOverridden(record, props.collection.name, currentFieldsByName);
return (
<Space>
{configureFieldsDisabled ? null : (
<Typography.Link disabled={overridden} onClick={() => openInheritedFieldOverride(record)}>
{t('Override')}
</Typography.Link>
)}
<Typography.Link onClick={() => openInheritedFieldView(record, record.__parentTitle)}>
{t('View')}
</Typography.Link>
</Space>
);
},
},
],
[
configureFieldsDisabled,
ctx.dataSourceManager,
currentFieldsByName,
fieldInterfacesByName,
handleTitleFieldChange,
openInheritedFieldOverride,
openInheritedFieldView,
props.collection.name,
t,
titleField,
titleFieldLoadingKey,
],
);
const inheritedGroupColumns = useMemo<ColumnsType<InheritedFieldGroup>>(
() => [
{
dataIndex: 'title',
render: (_, record) => (
<strong>
{t('Inherited fields')} - {compileLegacyTemplate(record.title || record.collectionName, t)}
</strong>
),
},
],
[t],
);
const handleSyncFields = useCallback(async () => {
if (TemplateSyncFieldsDrawer) {
openTemplateSyncFieldsDrawer();
@@ -1187,16 +1445,6 @@ export default function FieldsPage(props: FieldsPageProps) {
t,
]);
const syncFieldsVisible = isSyncFieldsVisible(props.dataSourceKey, props.collection, ctx);
const configureFieldsDisabled = Boolean(dataSourceType?.disableConfigureFields);
const fieldDeletionVisible = isMainDataSource && !configureFieldsDisabled;
const addFieldVisible = isAddFieldVisible({
collection: props.collection,
ctx,
dataSourceType: dataSource?.options?.type,
fieldInterfaceGroups,
});
useEffect(() => {
if (!fieldDeletionVisible) {
setSelectedRowKeys([]);
@@ -1292,7 +1540,14 @@ export default function FieldsPage(props: FieldsPageProps) {
render: (_, record) => (
<Space>
<a onClick={() => openFieldForm('edit', record)}>{t('Edit')}</a>
{fieldDeletionVisible ? <a onClick={() => handleDelete(record.name)}>{t('Delete')}</a> : null}
{fieldDeletionVisible ? (
<Typography.Link
disabled={isFieldDeleteDisabled(record, collectionTemplate)}
onClick={() => handleDelete(record.name)}
>
{t('Delete')}
</Typography.Link>
) : null}
</Space>
),
});
@@ -1303,6 +1558,7 @@ export default function FieldsPage(props: FieldsPageProps) {
allFieldInterfaceGroups,
configureFieldsDisabled,
fieldDeletionVisible,
collectionTemplate,
ctx.dataSourceManager,
dataSourceType,
fieldInterfacesByName,
@@ -1357,10 +1613,39 @@ export default function FieldsPage(props: FieldsPageProps) {
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (record) => ({
disabled: isFieldDeleteDisabled(record, collectionTemplate),
}),
}
: undefined
}
/>
{inheritedFieldGroups.some((group) => group.fields.length) ? (
<AntdTable<InheritedFieldGroup>
style={{ marginTop: 16 }}
rowKey="key"
columns={inheritedGroupColumns}
dataSource={inheritedFieldGroups.filter((group) => group.fields.length)}
pagination={false}
showHeader={false}
expandable={{
defaultExpandAllRows: true,
defaultExpandedRowKeys: inheritedFieldGroups.map((group) => group.key),
expandedRowRender: (record) => (
<AntdTable<Record<string, any>>
rowKey="name"
columns={inheritedFieldColumns}
dataSource={record.fields.map((field) => ({
...field,
__parentTitle: record.title,
}))}
pagination={false}
showHeader={false}
/>
),
}}
/>
) : null}
</>
);
}
@@ -8,6 +8,7 @@
*/
import { compileLegacyTemplate } from '../../../utils/compileLegacyTemplate';
import { getInheritedFieldGroups, isFieldDeleteDisabled, isInheritedFieldOverridden } from '../FieldsPage';
import { getPresetFieldRows } from '../CollectionsPage';
describe('getPresetFieldRows', () => {
@@ -46,3 +47,62 @@ describe('getPresetFieldRows', () => {
expect(compileLegacyTemplate(rows[0].field, t)).toBe('空间');
});
});
describe('field delete helpers', () => {
it('disables deleting fields marked as non-deletable by the collection template', () => {
const fileTemplate = {
collection: {
fields: [
{ name: 'title', deletable: false },
{ name: 'filename', deletable: false },
],
},
};
expect(isFieldDeleteDisabled({ name: 'title' }, fileTemplate)).toBe(true);
expect(isFieldDeleteDisabled({ name: 'customName' }, fileTemplate)).toBe(false);
});
it('disables deleting fields whose record metadata is non-deletable', () => {
expect(isFieldDeleteDisabled({ name: 'title', deletable: false })).toBe(true);
expect(isFieldDeleteDisabled({ name: 'title', deletable: true })).toBe(false);
});
});
describe('inherited field helpers', () => {
function createCollection(name: string, fields: Array<Record<string, unknown>>, inherits: unknown[] = []) {
return {
name,
title: name,
fields: new Map(fields.map((field) => [field.name, { options: field }])),
inherits: new Map(inherits.map((collection: { name: string }) => [collection.name, collection])),
};
}
it('returns direct and ancestor inherited field groups using each parent collection own fields', () => {
const grandparent = createCollection('grandparent', [{ name: 'grandparentField', interface: 'input' }]);
const parent = createCollection('parent', [{ name: 'parentField', interface: 'input' }], [grandparent]);
const child = createCollection('child', [{ name: 'childField', interface: 'input' }], [parent]);
expect(
getInheritedFieldGroups(child).map((group) => ({
collectionName: group.collectionName,
fields: group.fields.map((field) => field.name),
})),
).toEqual([
{ collectionName: 'parent', fields: ['parentField'] },
{ collectionName: 'grandparent', fields: ['grandparentField'] },
]);
});
it('treats a same-name current collection field as an overridden inherited field', () => {
const currentFieldsByName = new Map([
['title', { name: 'title', collectionName: 'orders-1' }],
['status', { name: 'status', collectionName: 'orders' }],
]);
expect(isInheritedFieldOverridden({ name: 'title' }, 'orders-1', currentFieldsByName)).toBe(true);
expect(isInheritedFieldOverridden({ name: 'status' }, 'orders-1', currentFieldsByName)).toBe(false);
expect(isInheritedFieldOverridden({ name: 'amount' }, 'orders-1', currentFieldsByName)).toBe(false);
});
});