feat: association snapshot (#1438)
* feat: snapshota * feat: snapshota i18n & bugs * feat: snapshota association value fix * feat: snapshota remove require true * feat: snapshota params.values null fix * feat: snapshota i18n * feat: snapshota CR fix * feat: snapshota field change fix * feat: snapshota magicstring fix * feat: snapshota field del fix * feat: snapshota CR fix * feat: snapshota tag fix * feat: snapshota depth < 3 * fix: improve code * feat: snapshota test * feat: snapshota remove disabled * fix: disabled isOverride --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
7fda978fe8
commit
6c837ee08c
@ -92,7 +92,7 @@ export const useResourceAction = (props, opts = {}) => {
|
||||
*/
|
||||
const { resource, action, fieldName: tableFieldName } = props;
|
||||
const { fields } = useCollection();
|
||||
const appends = fields?.filter((field) => field.target && field.interface !== 'snapshot').map((field) => field.name);
|
||||
const appends = fields?.filter((field) => field.target).map((field) => field.name);
|
||||
const params = useActionParams(props);
|
||||
const api = useAPIClient();
|
||||
const fieldSchema = useFieldSchema();
|
||||
|
@ -75,7 +75,7 @@ const useAssociationNames = (collection) => {
|
||||
const collectionFields = getCollectionFields(collection);
|
||||
const associationFields = new Set();
|
||||
for (const collectionField of collectionFields) {
|
||||
if (collectionField.target && collectionField.interface !== 'snapshot') {
|
||||
if (collectionField.target) {
|
||||
associationFields.add(collectionField.name);
|
||||
const fields = getCollectionFields(collectionField.target);
|
||||
for (const field of fields) {
|
||||
|
@ -6,7 +6,6 @@ import { useCollectionManager } from '../collection-manager';
|
||||
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
|
||||
import { useFixedSchema } from '../schema-component';
|
||||
|
||||
|
||||
export const TableBlockContext = createContext<any>({});
|
||||
|
||||
const InternalTableBlockProvider = (props) => {
|
||||
@ -39,7 +38,7 @@ export const useAssociationNames = (collection) => {
|
||||
const collectionFields = getCollectionFields(collection);
|
||||
const associationFields = new Set();
|
||||
for (const collectionField of collectionFields) {
|
||||
if (collectionField.target && collectionField.interface !== 'snapshot') {
|
||||
if (collectionField.target) {
|
||||
associationFields.add(collectionField.name);
|
||||
const fields = getCollectionFields(collectionField.target);
|
||||
for (const field of fields) {
|
||||
|
@ -37,7 +37,7 @@ const InternalTableSelectorProvider = (props) => {
|
||||
const useAssociationNames2 = (collection) => {
|
||||
const { getCollectionFields } = useCollectionManager();
|
||||
const names = getCollectionFields(collection)
|
||||
?.filter((field) => field.target && field.interface !== 'snapshot')
|
||||
?.filter((field) => field.target)
|
||||
.map((field) => field.name);
|
||||
return names;
|
||||
};
|
||||
@ -55,7 +55,7 @@ const useAssociationNames = (collection) => {
|
||||
const collectionFields = getCollectionFields(collection);
|
||||
const associationFields = new Set();
|
||||
for (const collectionField of collectionFields) {
|
||||
if (collectionField.target && collectionField.interface !== 'snapshot') {
|
||||
if (collectionField.target) {
|
||||
associationFields.add(collectionField.name);
|
||||
const fields = getCollectionFields(collectionField.target);
|
||||
for (const field of fields) {
|
||||
|
@ -3,7 +3,9 @@ import { CollectionContext } from './context';
|
||||
import { useCollectionManager } from './hooks';
|
||||
import { CollectionOptions } from './types';
|
||||
|
||||
export const CollectionProvider: React.FC<{ allowNull?: boolean; name?: string; collection?: CollectionOptions }> = (props) => {
|
||||
export const CollectionProvider: React.FC<{ allowNull?: boolean; name?: string; collection?: CollectionOptions }> = (
|
||||
props,
|
||||
) => {
|
||||
const { allowNull, name, collection, children } = props;
|
||||
const { getCollection } = useCollectionManager();
|
||||
const value = getCollection(collection || name);
|
||||
|
@ -67,7 +67,9 @@ FormItem.Designer = (props) => {
|
||||
const interfaceConfig = getInterface(collectionField?.interface);
|
||||
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
|
||||
const originalTitle = collectionField?.uiSchema?.title;
|
||||
const targetFields = collectionField?.target ? getCollectionFields(collectionField.target) : [];
|
||||
const targetFields = collectionField?.target
|
||||
? getCollectionFields(collectionField.target)
|
||||
: getCollectionFields(collectionField?.targetCollection) ?? [];
|
||||
const fieldComponentOptions = useFieldComponentOptions();
|
||||
const isSubFormAssocitionField = field.address.segments.includes('__form_grid');
|
||||
const initialValue = {
|
||||
@ -466,7 +468,7 @@ FormItem.Designer = (props) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{collectionField?.target && fieldSchema['x-component'] === 'CollectionField' && (
|
||||
{options.length > 0 && fieldSchema['x-component'] === 'CollectionField' && (
|
||||
<SchemaSettings.SelectItem
|
||||
key="title-field"
|
||||
title={t('Title field')}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Field } from '@formily/core';
|
||||
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
|
||||
import { observer, RecursionField, useFieldSchema } from '@formily/react';
|
||||
import { toArr } from '@formily/shared';
|
||||
import React, { Fragment, useRef, useState } from 'react';
|
||||
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
|
||||
@ -97,7 +96,7 @@ export const ReadPrettyRecordPicker: React.FC = observer((props: any) => {
|
||||
return collectionField ? (
|
||||
<div>
|
||||
<BlockAssociationContext.Provider value={`${collectionField.collectionName}.${collectionField.name}`}>
|
||||
<CollectionProvider name={collectionField.target}>
|
||||
<CollectionProvider name={collectionField.target ?? collectionField.targetCollection}>
|
||||
<EllipsisWithTooltip ellipsis={ellipsis} ref={ellipsisWithTooltipRef}>
|
||||
{renderRecords()}
|
||||
</EllipsisWithTooltip>
|
||||
|
@ -31,7 +31,7 @@ export const TableColumnDesigner = (props) => {
|
||||
const { dn } = useDesignable();
|
||||
const fieldNames =
|
||||
fieldSchema?.['x-component-props']?.['fieldNames'] || uiSchema?.['x-component-props']?.['fieldNames'];
|
||||
const options = useLabelFields(collectionField?.target);
|
||||
const options = useLabelFields(collectionField?.target ?? collectionField?.targetCollection);
|
||||
const intefaceCfg = getInterface(collectionField?.interface);
|
||||
|
||||
return (
|
||||
|
@ -0,0 +1,12 @@
|
||||
export const field_linkto = {
|
||||
name: 'field_linkto',
|
||||
type: 'belongsToMany',
|
||||
uiSchema: {
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'field_linkto',
|
||||
},
|
||||
interface: 'linkTo',
|
||||
target: 'table_a',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
export const field_m2m = {
|
||||
foreignKey: 'fk_table_b',
|
||||
otherKey: 'fk_table_a',
|
||||
name: 'field_m2m',
|
||||
type: 'belongsToMany',
|
||||
uiSchema: {
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'field_m2m',
|
||||
},
|
||||
interface: 'm2m',
|
||||
through: 'table_m2m',
|
||||
target: 'table_a',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
export const field_o2m = {
|
||||
foreignKey: 'fk_table_b',
|
||||
onDelete: 'SET NULL',
|
||||
name: 'field_o2m',
|
||||
type: 'hasMany',
|
||||
uiSchema: {
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'field_o2m',
|
||||
},
|
||||
interface: 'o2m',
|
||||
target: 'table_a',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
export const field_o2o = {
|
||||
foreignKey: 'fk_table_b',
|
||||
onDelete: 'SET NULL',
|
||||
name: 'field_o2o',
|
||||
type: 'hasOne',
|
||||
uiSchema: {
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'field_o2o',
|
||||
},
|
||||
interface: 'oho',
|
||||
target: 'table_a',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
export const snap_linkto = {
|
||||
name: 'snap_linkto',
|
||||
type: 'snapshot',
|
||||
uiSchema: {
|
||||
'x-component': 'SnapshotRecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'snap_linkto',
|
||||
},
|
||||
interface: 'snapshot',
|
||||
targetField: 'field_linkto',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
export const snap_m2m = {
|
||||
name: 'snap_m2m',
|
||||
type: 'snapshot',
|
||||
uiSchema: {
|
||||
'x-component': 'SnapshotRecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'snap_m2m',
|
||||
},
|
||||
interface: 'snapshot',
|
||||
targetField: 'field_m2m',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
export const snap_o2m = {
|
||||
name: 'snap_o2m',
|
||||
type: 'snapshot',
|
||||
uiSchema: {
|
||||
'x-component': 'SnapshotRecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'snap_o2m',
|
||||
},
|
||||
interface: 'snapshot',
|
||||
targetField: 'field_o2m',
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
export const snap_o2o = {
|
||||
name: 'snap_o2o',
|
||||
type: 'snapshot',
|
||||
uiSchema: {
|
||||
'x-component': 'SnapshotRecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
title: 'snap_o2o',
|
||||
},
|
||||
interface: 'snapshot',
|
||||
targetField: 'field_o2o',
|
||||
appends: ['createdBy'],
|
||||
collectionName: 'table_b',
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
export const table_a = {
|
||||
logging: true,
|
||||
autoGenId: true,
|
||||
createdBy: true,
|
||||
updatedBy: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
sortable: true,
|
||||
name: 'table_a',
|
||||
template: 'general',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
|
||||
interface: 'id',
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
interface: 'createdBy',
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
foreignKey: 'createdById',
|
||||
uiSchema: {
|
||||
type: 'object',
|
||||
title: '{{t("Created by")}}',
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } },
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'table_a',
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
export const table_b = {
|
||||
logging: true,
|
||||
autoGenId: true,
|
||||
createdBy: true,
|
||||
updatedBy: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
sortable: true,
|
||||
name: 'table_b',
|
||||
template: 'general',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
|
||||
interface: 'id',
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
interface: 'createdBy',
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
foreignKey: 'createdById',
|
||||
uiSchema: {
|
||||
type: 'object',
|
||||
title: '{{t("Created by")}}',
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } },
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'table_b',
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
export const table_m2m = {
|
||||
logging: true,
|
||||
autoGenId: true,
|
||||
createdBy: true,
|
||||
updatedBy: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
sortable: true,
|
||||
name: 'table_m2m',
|
||||
template: 'general',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
|
||||
interface: 'id',
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
interface: 'createdBy',
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
foreignKey: 'createdById',
|
||||
uiSchema: {
|
||||
type: 'object',
|
||||
title: '{{t("Created by")}}',
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } },
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'table_m2m',
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import SnapshotFieldPlugin from '../server';
|
||||
import path from 'path';
|
||||
|
||||
describe('actions', () => {
|
||||
let app: MockServer;
|
||||
@ -22,7 +21,7 @@ describe('actions', () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it.only('fieldsHistory collectionName and name conflict between tables', async () => {
|
||||
it('fieldsHistory collectionName and name conflict between tables', async () => {
|
||||
const agent = app.agent();
|
||||
|
||||
const field = {
|
||||
|
155
packages/plugins/snapshot-field/src/__tests__/snapshots.test.ts
Normal file
155
packages/plugins/snapshot-field/src/__tests__/snapshots.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import SnapshotFieldPlugin from '../server';
|
||||
import { field_linkto } from './data/field_linkto';
|
||||
import { field_m2m } from './data/field_m2m';
|
||||
import { field_o2m } from './data/field_o2m';
|
||||
import { field_o2o } from './data/field_o2o';
|
||||
import { snap_linkto } from './data/snap_linkto';
|
||||
import { snap_m2m } from './data/snap_m2m';
|
||||
import { snap_o2m } from './data/snap_o2m';
|
||||
import { snap_o2o } from './data/snap_o2o';
|
||||
import { table_a } from './data/table_a';
|
||||
import { table_b } from './data/table_b';
|
||||
import { table_m2m } from './data/table_m2m';
|
||||
|
||||
describe('actions', () => {
|
||||
let app: MockServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = mockServer({
|
||||
registerActions: true,
|
||||
acl: false,
|
||||
plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager'],
|
||||
});
|
||||
|
||||
app.plugin(SnapshotFieldPlugin, { name: 'snapshot-field' });
|
||||
|
||||
await app.loadAndInstall({ clean: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.cleanDb();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it.only('associations save', async () => {
|
||||
const agent = app.agent();
|
||||
|
||||
await agent.resource('collections').create({
|
||||
values: table_a,
|
||||
});
|
||||
|
||||
await agent.resource('collections').create({
|
||||
values: table_b,
|
||||
});
|
||||
|
||||
await agent.resource('collections').create({
|
||||
values: table_m2m,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: field_o2m,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: field_m2m,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: field_o2o,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: field_linkto,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: snap_o2m,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: snap_m2m,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: snap_o2o,
|
||||
});
|
||||
|
||||
await agent.resource('fields').create({
|
||||
values: snap_linkto,
|
||||
});
|
||||
|
||||
await agent.resource('table_a').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
await agent.resource('table_b').create({
|
||||
values: {
|
||||
field_o2m: [{ createdById: 1, id: 1, fk_table_b: null }],
|
||||
field_m2m: [{ createdById: 1, id: 1, fk_table_b: null }],
|
||||
field_o2o: { createdById: 1, id: 1, fk_table_b: null },
|
||||
field_linkto: [{ createdById: 1, id: 1, fk_table_b: null }],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.resource('table_b').list();
|
||||
const { snap_o2o: o2o, snap_m2m: m2m, snap_o2m: o2m, snap_linkto: linkto } = res.body.data[0];
|
||||
|
||||
const snapshotItem = { createdById: null, id: 1, fk_table_b: 1 };
|
||||
|
||||
expect(o2o).toMatchObject({
|
||||
collectionName: 'table_b',
|
||||
data: snapshotItem,
|
||||
});
|
||||
expect(m2m).toMatchObject({
|
||||
collectionName: 'table_b',
|
||||
data: [snapshotItem],
|
||||
});
|
||||
expect(o2m).toMatchObject({
|
||||
collectionName: 'table_b',
|
||||
data: [snapshotItem],
|
||||
});
|
||||
expect(linkto).toMatchObject({
|
||||
collectionName: 'table_b',
|
||||
data: [snapshotItem],
|
||||
});
|
||||
|
||||
await agent.resource('table_a').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
await agent.resource('collections.fields', 'table_b').destroy({ filter: { name: 'field_o2m' } });
|
||||
|
||||
const { statusCode: code2 } = await agent.resource('table_b').create({
|
||||
values: {
|
||||
field_m2m: [{ createdById: 1, id: 2, fk_table_b: null }],
|
||||
field_o2o: { createdById: 1, id: 2, fk_table_b: null },
|
||||
field_linkto: [{ createdById: 1, id: 2, fk_table_b: null }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(code2).toBe(200);
|
||||
|
||||
const { statusCode: code3 } = await agent
|
||||
.resource('collections.fields', 'table_a')
|
||||
.destroy({ filter: { name: 'createdBy' } });
|
||||
|
||||
expect(code3).toBe(200);
|
||||
|
||||
const { statusCode: code4 } = await agent.resource('table_a').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
expect(code4).toBe(200);
|
||||
|
||||
const { statusCode: code5 } = await agent.resource('table_b').create({
|
||||
values: {
|
||||
field_m2m: [{ createdById: 1, id: 3, fk_table_b: null }],
|
||||
field_o2o: { createdById: 1, id: 3, fk_table_b: null },
|
||||
field_linkto: [{ createdById: 1, id: 3, fk_table_b: null }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(code5).toBe(200);
|
||||
});
|
||||
});
|
@ -41,38 +41,4 @@ export const SnapshotBlockInitializers = (props: any) => {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
// const { t } = useSnapshotTranslation();
|
||||
// return (
|
||||
// <SchemaInitializer.Button
|
||||
// wrap={gridRowColWrap}
|
||||
// title={t('Add block')}
|
||||
// icon={'PlusOutlined'}
|
||||
// items={[
|
||||
// {
|
||||
// type: 'itemGroup',
|
||||
// title: '{{ t("Data blocks") }}',
|
||||
// children: [
|
||||
// {
|
||||
// type: 'item',
|
||||
// title: '{{t("Details")}}',
|
||||
// component: 'SnapshotBlockInitializersDetailItem',
|
||||
// actionInitializers: 'CalendarFormActionInitializers',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// type: 'itemGroup',
|
||||
// title: '{{t("Other blocks")}}',
|
||||
// children: [
|
||||
// {
|
||||
// type: 'item',
|
||||
// title: '{{t("Markdown")}}',
|
||||
// component: 'MarkdownBlockInitializer',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ]}
|
||||
// />
|
||||
// );
|
||||
};
|
||||
|
@ -1,41 +1,22 @@
|
||||
import React from 'react';
|
||||
import { connect, mapReadPretty, useFieldSchema } from '@formily/react';
|
||||
import {
|
||||
InputRecordPicker,
|
||||
ReadPrettyRecordPicker,
|
||||
useActionContext,
|
||||
useCollection,
|
||||
useCollectionHistory,
|
||||
} from '@nocobase/client';
|
||||
import { ReadPrettyRecordPicker, useCollection } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { SnapshotHistoryCollectionProvider } from './SnapshotHistoryCollectionProvider';
|
||||
|
||||
const useSnapshotFieldTargetCollectionName = () => {
|
||||
const ReadPrettyRecordPickerWrapper = (props) => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const { getField } = useCollection();
|
||||
const collectionField = getField(fieldSchema.name);
|
||||
const { historyCollections } = useCollectionHistory();
|
||||
return historyCollections.find((i) => i.name === collectionField.target)?.name;
|
||||
};
|
||||
|
||||
const ReadPrettyRecordPickerWrapper = (props) => {
|
||||
const collectionName = useSnapshotFieldTargetCollectionName();
|
||||
|
||||
return (
|
||||
<SnapshotHistoryCollectionProvider collectionName={collectionName}>
|
||||
<SnapshotHistoryCollectionProvider collectionName={collectionField?.targetCollection}>
|
||||
<ReadPrettyRecordPicker {...props} />
|
||||
</SnapshotHistoryCollectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const SnapshotRecordPickerInner: any = connect(
|
||||
(props) => {
|
||||
const actionCtx = useActionContext();
|
||||
|
||||
const isUpdateAction = actionCtx.fieldSchema['x-action'] === 'update';
|
||||
|
||||
return isUpdateAction ? <ReadPrettyRecordPickerWrapper {...props} /> : <InputRecordPicker {...props} />;
|
||||
},
|
||||
// mapProps(mapSuffixProps),
|
||||
ReadPrettyRecordPickerWrapper,
|
||||
mapReadPretty(ReadPrettyRecordPickerWrapper),
|
||||
);
|
||||
|
||||
|
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { TreeSelect, Tag } from 'antd';
|
||||
import type { DefaultOptionType } from 'rc-tree-select/lib/TreeSelect';
|
||||
import { useForm } from '@formily/react';
|
||||
import { CollectionFieldOptions, useCollectionManager, useCompile, useRecord } from '@nocobase/client';
|
||||
import { useSnapshotTranslation } from '../locale';
|
||||
import { useTopRecord } from '../interface';
|
||||
|
||||
export type TreeCacheMapNode = {
|
||||
parent?: TreeCacheMapNode;
|
||||
title: string;
|
||||
path: string;
|
||||
children?: TreeCacheMapNode[];
|
||||
};
|
||||
|
||||
export type AppendsTreeSelectProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string };
|
||||
|
||||
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
const { value = [], onChange, ...restProps } = props;
|
||||
const record = useTopRecord();
|
||||
const { getCollectionFields, getCollectionField } = useCollectionManager();
|
||||
const compile = useCompile();
|
||||
const formValues = useForm().values;
|
||||
const { t } = useSnapshotTranslation();
|
||||
|
||||
const fieldsToOptions = (
|
||||
fields: CollectionFieldOptions[] = [],
|
||||
fieldPath: CollectionFieldOptions[] = [],
|
||||
): TreeOptionType[] => {
|
||||
const filter = (i: CollectionFieldOptions) =>
|
||||
!!i.target && !!i.interface && fieldPath.length < 3 && !fieldPath.find((p) => p.target === i.target);
|
||||
return fields.filter(filter).map((i) => ({
|
||||
title: compile(i.uiSchema?.title) ?? i.name,
|
||||
value: fieldPath
|
||||
.map((p) => p.name)
|
||||
.concat(i.name)
|
||||
.join('.'),
|
||||
children: fieldsToOptions(getCollectionFields(i.target), [...fieldPath, i]),
|
||||
}));
|
||||
};
|
||||
|
||||
const treeData = fieldsToOptions(
|
||||
getCollectionFields(getCollectionField(`${record.name}.${formValues.targetField}`)?.target),
|
||||
);
|
||||
|
||||
const valueMap: Record<string, TreeCacheMapNode> = {};
|
||||
|
||||
function loops(list: TreeOptionType[], parent?: TreeCacheMapNode) {
|
||||
return (list || []).map(({ children, value, title }) => {
|
||||
const node: TreeCacheMapNode = (valueMap[value] = {
|
||||
parent,
|
||||
path: value,
|
||||
title,
|
||||
});
|
||||
node.children = loops(children, node);
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
loops(treeData);
|
||||
|
||||
const handleChange = (newNodes: DefaultOptionType[]) => {
|
||||
const newValue = newNodes.map((i) => i.value) as string[];
|
||||
const valueSet = new Set(newValue);
|
||||
const delValue = value.find((i) => !newValue.includes(i));
|
||||
|
||||
if (delValue) {
|
||||
const delNode = valueMap[delValue];
|
||||
const delNodeValue = (node: TreeCacheMapNode) => {
|
||||
valueSet.delete(node.path);
|
||||
node.children?.forEach((child) => delNodeValue(child));
|
||||
};
|
||||
delNodeValue(delNode);
|
||||
} else {
|
||||
newValue.forEach((v) => {
|
||||
let current = valueMap[v];
|
||||
while ((current = current.parent)) {
|
||||
valueSet.add(current.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
onChange(Array.from(valueSet));
|
||||
};
|
||||
|
||||
const TreeTag = (props) => {
|
||||
const { value, onClose, disabled, closable } = props;
|
||||
let node = valueMap[value];
|
||||
let text = node?.title;
|
||||
while ((node = node?.parent)) {
|
||||
text = `${node.title} / ${text}`;
|
||||
}
|
||||
return (
|
||||
<Tag closable={closable && !disabled} onClose={onClose}>
|
||||
{text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const filterdValue = Array.isArray(value) ? value.filter((i) => i in valueMap) : value;
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
value={filterdValue}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
placeholder={t('Please select')}
|
||||
showCheckedStrategy="SHOW_ALL"
|
||||
allowClear
|
||||
multiple
|
||||
treeCheckStrictly
|
||||
treeCheckable
|
||||
tagRender={TreeTag}
|
||||
onChange={handleChange as unknown as () => void}
|
||||
treeData={treeData}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { useTopRecord } from '../interface';
|
||||
import { useCollectionManager, useCompile } from '@nocobase/client';
|
||||
|
||||
export type SnapshotOwnerCollectionFieldsSelectProps = Omit<SelectProps, 'options'>;
|
||||
|
||||
export const useSnapshotOwnerCollectionFields = () => {
|
||||
const record = useTopRecord();
|
||||
const { getCollection } = useCollectionManager();
|
||||
const collection = getCollection(record.name);
|
||||
const compile = useCompile();
|
||||
|
||||
return collection.fields
|
||||
.filter((i) => !!i.target && !!i.interface)
|
||||
.map((i) => ({ ...i, label: compile(i.uiSchema?.title), value: i.name }));
|
||||
};
|
||||
|
||||
export const SnapshotOwnerCollectionFieldsSelect: React.FC<SnapshotOwnerCollectionFieldsSelectProps> = (props) => {
|
||||
const options = useSnapshotOwnerCollectionFields();
|
||||
return <Select options={options} {...props} />;
|
||||
};
|
@ -6,12 +6,14 @@ import {
|
||||
SchemaInitializerContext,
|
||||
SchemaInitializerProvider,
|
||||
} from '@nocobase/client';
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useSnapshotInterface } from './interface';
|
||||
import { SnapshotRecordPicker } from './SnapshotRecordPicker';
|
||||
import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers';
|
||||
import { SnapshotBlockInitializersDetailItem } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializersDetailItem';
|
||||
import { SnapshotBlockProvider } from './SnapshotBlock/SnapshotBlockProvider';
|
||||
import { AppendsTreeSelect } from './components/AppendsTreeSelect';
|
||||
import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerCollectionFieldsSelect';
|
||||
|
||||
export default React.memo((props) => {
|
||||
const initializers = useContext(SchemaInitializerContext);
|
||||
@ -39,6 +41,8 @@ export default React.memo((props) => {
|
||||
SnapshotRecordPicker,
|
||||
SnapshotBlockProvider,
|
||||
SnapshotBlockInitializersDetailItem,
|
||||
AppendsTreeSelect,
|
||||
SnapshotOwnerCollectionFieldsSelect,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
@ -1,10 +1,31 @@
|
||||
import type { Field } from '@formily/core';
|
||||
import { ISchema } from '@formily/react';
|
||||
import { IField, interfacesProperties } from '@nocobase/client';
|
||||
import { IField, interfacesProperties, useRecord } from '@nocobase/client';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { useSnapshotTranslation } from './locale';
|
||||
|
||||
const { defaultProps, recordPickerSelector } = interfacesProperties;
|
||||
const { defaultProps } = interfacesProperties;
|
||||
|
||||
const APPENDS = 'appends';
|
||||
const TARGET_FIELD = 'targetField';
|
||||
|
||||
export const useTopRecord = () => {
|
||||
let record = useRecord();
|
||||
|
||||
while (record && Object.keys(record.__parent).length > 0) {
|
||||
record = record.__parent;
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
const onTargetFieldChange = (field: Field) => {
|
||||
field.value; // for watch
|
||||
const targetField = field.query(`.${APPENDS}`).take() as Field | undefined;
|
||||
console.log(field.path);
|
||||
if (!targetField) return;
|
||||
!targetField.getState().disabled && targetField.setValue([]);
|
||||
};
|
||||
|
||||
export const useSnapshotInterface = () => {
|
||||
const { t } = useSnapshotTranslation();
|
||||
@ -56,7 +77,6 @@ export const useSnapshotInterface = () => {
|
||||
// title,
|
||||
'x-component': 'SnapshotRecordPicker',
|
||||
'x-component-props': {
|
||||
// mode: 'tags',
|
||||
multiple: true,
|
||||
fieldNames: {
|
||||
label: 'id',
|
||||
@ -66,33 +86,52 @@ export const useSnapshotInterface = () => {
|
||||
},
|
||||
},
|
||||
schemaInitialize(schema: ISchema, { field, readPretty, action, block }) {
|
||||
if (readPretty || action === 'update') {
|
||||
schema['properties'] = {
|
||||
viewer: cloneDeep(recordPickerViewer),
|
||||
};
|
||||
} else {
|
||||
schema['properties'] = {
|
||||
selector: cloneDeep(recordPickerSelector),
|
||||
};
|
||||
}
|
||||
schema['properties'] = {
|
||||
viewer: cloneDeep(recordPickerViewer),
|
||||
};
|
||||
},
|
||||
initialize: (values: any) => {},
|
||||
properties: {
|
||||
...defaultProps,
|
||||
target: {
|
||||
[TARGET_FIELD]: {
|
||||
type: 'string',
|
||||
title: t('Target collection'),
|
||||
title: t('Association field'),
|
||||
required: true,
|
||||
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
'x-disabled': '{{ !createOnly }}',
|
||||
'x-component': 'SnapshotOwnerCollectionFieldsSelect',
|
||||
'x-disabled': '{{ !createOnly || isOverride }}',
|
||||
'x-reactions': [
|
||||
{
|
||||
target: APPENDS,
|
||||
when: '{{$self.value != undefined}}',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
otherwise: {
|
||||
state: {
|
||||
visible: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'uiSchema.x-component-props.multiple': {
|
||||
type: 'boolean',
|
||||
'x-content': t('Allow linking to multiple records'),
|
||||
[APPENDS]: {
|
||||
type: 'string',
|
||||
title: t('Deep copy fields'),
|
||||
description: t('When a record is created, relational data is backed up in a snapshot'),
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
'x-component': 'AppendsTreeSelect',
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: [TARGET_FIELD],
|
||||
when: '{{$deps[0]}}',
|
||||
fulfill: {
|
||||
run: '{{$self.setValue($self.value)}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -6,5 +6,9 @@ export default {
|
||||
'Allow linking to multiple records': 'Allow linking to multiple records',
|
||||
'Snapshot to description':
|
||||
'It is used to create a snapshot of the table, save the current data of the pointed table, and save it only when the record to which it belongs is created, and will not be updated later.',
|
||||
'Target collection': 'Target collection',
|
||||
'Association field': 'Association field',
|
||||
'Deep copy fields': 'Deep copy fields',
|
||||
'Please select': 'Please select',
|
||||
'When a record is created, relational data is backed up in a snapshot':
|
||||
'When a record is created, relational data is backed up in a snapshot',
|
||||
};
|
||||
|
@ -5,5 +5,8 @@ export default {
|
||||
'Snapshot to description': '用于创建表的快照,保存指向的表的当前数据,仅在其所属的记录创建时保存,后续不再更新。',
|
||||
'View record': '查看数据',
|
||||
'Allow linking to multiple records': '允许关联多条记录',
|
||||
'Target collection': '关系表',
|
||||
'Association field': '关系字段',
|
||||
'Deep copy fields': '深复制的字段',
|
||||
'Please select': '请选择',
|
||||
'When a record is created, association data is backed up in a snapshot': '创建记录时,关系数据会备份到快照里',
|
||||
};
|
||||
|
@ -1,12 +1,64 @@
|
||||
import { Field, BaseColumnFieldOptions } from '@nocobase/database';
|
||||
import { BaseColumnFieldOptions, CreateOptions, Field, Model } from '@nocobase/database';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export class SnapshotField extends Field {
|
||||
get dataType() {
|
||||
return DataTypes.JSON;
|
||||
}
|
||||
|
||||
createSnapshot = async (model: Model, { transaction, values }: CreateOptions) => {
|
||||
const { name, targetField } = this.options;
|
||||
const collectionName = this.collection.name;
|
||||
const primaryKey = this.collection.model.primaryKeyAttribute;
|
||||
|
||||
if (!this.collection.hasField(targetField)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repository = this.database.getRepository<any>(`${collectionName}.${targetField}`, model.get(primaryKey));
|
||||
const appends = (this.options.appends || []).filter((appendName) =>
|
||||
repository.targetCollection.hasField(appendName),
|
||||
);
|
||||
|
||||
let data = await repository.find({
|
||||
transaction,
|
||||
appends,
|
||||
});
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data = data.map((i) => i.toJSON());
|
||||
} else if (data?.toJSON) {
|
||||
data = data.toJSON();
|
||||
}
|
||||
|
||||
await model.update(
|
||||
{
|
||||
[name]: {
|
||||
collectionName,
|
||||
data,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
hooks: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
bind() {
|
||||
super.bind();
|
||||
this.on('afterCreateWithAssociations', this.createSnapshot);
|
||||
}
|
||||
|
||||
unbind() {
|
||||
super.unbind();
|
||||
this.off('afterCreateWithAssociations', this.createSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SnapshotFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'snapshot';
|
||||
targetField: string;
|
||||
targetCollection?: string;
|
||||
appends?: string[];
|
||||
}
|
||||
|
@ -59,8 +59,21 @@ export class SnapshotFieldPlugin extends Plugin {
|
||||
};
|
||||
|
||||
this.app.db.on('fields.afterCreateWithAssociations', fieldHandler);
|
||||
this.app.db.on('fields.beforeCreate', this.autoFillTargetCollection);
|
||||
}
|
||||
|
||||
autoFillTargetCollection = async (model: Model) => {
|
||||
const { collectionName, targetField } = model.get();
|
||||
const collection = this.db.getCollection(collectionName);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const field = collection.getField(targetField);
|
||||
if (field?.target) {
|
||||
model.set('targetCollection', field.target);
|
||||
}
|
||||
};
|
||||
|
||||
async load() {
|
||||
// 导入 collection
|
||||
await this.db.import({
|
||||
|
Loading…
Reference in New Issue
Block a user