mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
Merge branch 'main' into next
This commit is contained in:
@@ -82,6 +82,15 @@ export class CollectionBlockModel<T = DefaultStructure> extends DataBlockModel<T
|
||||
const engine = this.context.engine as FlowEngine;
|
||||
const currentVersion = this.getDirtyTrackingVersion(engine, dataSourceKey, resource, params);
|
||||
|
||||
if (resource instanceof MultiRecordResource && this.getDataLoadingMode() === 'manual' && !this.hasActiveFilters()) {
|
||||
resource.setData([]);
|
||||
resource.setMeta({ count: 0, hasNext: false });
|
||||
resource.setPage(1);
|
||||
resource.loading = false;
|
||||
this.lastSeenDirtyVersion = currentVersion;
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceRefresh) {
|
||||
if (this.dirtyRefreshing) return;
|
||||
this.dirtyRefreshing = true;
|
||||
|
||||
+60
@@ -128,4 +128,64 @@ describe('CollectionBlockModel initial beforeRender refresh', () => {
|
||||
expect(resource.getData()).toEqual([]);
|
||||
expect(resource.getMeta('count')).toBe(0);
|
||||
});
|
||||
|
||||
it('skips forced active refresh in manual mode when filters are empty', async () => {
|
||||
const { model, resource } = setupModelWithManualMode();
|
||||
const refreshSpy = vi.spyOn(resource, 'refresh');
|
||||
|
||||
resource.setData([{ id: 2, name: 'Stale' }]);
|
||||
resource.setMeta({ count: 1, hasNext: true });
|
||||
resource.setPage(2);
|
||||
resource.loading = true;
|
||||
|
||||
model.onActive(true);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
expect(resource.getData()).toEqual([]);
|
||||
expect(resource.getMeta('count')).toBe(0);
|
||||
expect(resource.getMeta('hasNext')).toBe(false);
|
||||
expect(resource.getMeta('page')).toBe(1);
|
||||
expect(resource.getRequestParameter('page')).toBe(1);
|
||||
expect(resource.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps forced active refresh in manual mode when filters are active', async () => {
|
||||
const { model, resource } = setupModelWithManualMode();
|
||||
const refreshSpy = vi.spyOn(resource, 'refresh').mockResolvedValue();
|
||||
|
||||
resource.setData([{ id: 2, name: 'Stale' }]);
|
||||
resource.setMeta({ count: 1, hasNext: true, page: 2 });
|
||||
model.setFilterActive('filter-form-item', true);
|
||||
|
||||
model.onActive(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
expect(resource.getData()).toEqual([{ id: 2, name: 'Stale' }]);
|
||||
expect(resource.getMeta('count')).toBe(1);
|
||||
});
|
||||
|
||||
it('skips forced active refresh in manual mode when only resource filters are active', async () => {
|
||||
const { model, resource } = setupModelWithManualMode();
|
||||
const refreshSpy = vi.spyOn(resource, 'refresh');
|
||||
|
||||
resource.setData([{ id: 2, name: 'Stale' }]);
|
||||
resource.setMeta({ count: 1, hasNext: true });
|
||||
resource.setPage(2);
|
||||
resource.loading = true;
|
||||
resource.addFilterGroup('data-scope', { status: { $eq: 'active' } });
|
||||
|
||||
model.onActive(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
expect(resource.getData()).toEqual([]);
|
||||
expect(resource.getMeta('count')).toBe(0);
|
||||
expect(resource.getMeta('hasNext')).toBe(false);
|
||||
expect(resource.getMeta('page')).toBe(1);
|
||||
expect(resource.getRequestParameter('page')).toBe(1);
|
||||
expect(resource.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+125
@@ -45,6 +45,34 @@ const TEMPLATE_FALLBACK_PATCH_ORIGINAL_GET_STEP_PARAMS = Symbol.for(
|
||||
);
|
||||
const TARGET_OWN_CONTEXT_MISSING = Symbol.for('nocobase.referenceBlockTargetOwnContextMissing');
|
||||
|
||||
type FilterStateTarget = {
|
||||
setFilterActive?: (filterId: string, active: boolean) => void;
|
||||
hasActiveFilters?: () => boolean;
|
||||
removeFilterSource?: (filterId: string) => void;
|
||||
getDataLoadingMode?: () => 'auto' | 'manual';
|
||||
};
|
||||
|
||||
type PreparedFilterBlock = {
|
||||
markInitialTargetRefreshHandled?: (targetId: string) => void;
|
||||
};
|
||||
|
||||
type ReferenceFilterConfig = {
|
||||
filterId?: string;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
type ReferenceFilterManager = {
|
||||
getFilterConfigs?: () => ReferenceFilterConfig[];
|
||||
prepareFiltersForTarget?: (targetId: string) => Promise<Set<PreparedFilterBlock>>;
|
||||
bindToTarget?: (targetId: string) => void;
|
||||
};
|
||||
|
||||
type ReferenceFilterModel = {
|
||||
context?: {
|
||||
blockModel?: PreparedFilterBlock;
|
||||
};
|
||||
};
|
||||
|
||||
function isNonEmptyValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null && String(value).trim() !== '';
|
||||
}
|
||||
@@ -233,6 +261,83 @@ export class ReferenceBlockModel extends BlockModel {
|
||||
private _resolvedTargetUid?: string;
|
||||
private _invalidTargetUid?: string;
|
||||
|
||||
private _getFilterStateTarget(): FilterStateTarget | undefined {
|
||||
return this._targetModel as FilterStateTarget | undefined;
|
||||
}
|
||||
|
||||
private _getReferenceFilterManager(): ReferenceFilterManager | undefined {
|
||||
return this.context?.filterManager as ReferenceFilterManager | undefined;
|
||||
}
|
||||
|
||||
private _markShellInitialFilterRefreshHandled() {
|
||||
const filterConfigs = this._getReferenceFilterManager()?.getFilterConfigs?.();
|
||||
if (!Array.isArray(filterConfigs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterIds = new Set(
|
||||
filterConfigs
|
||||
.filter((config) => config.targetId === this.uid && typeof config.filterId === 'string' && config.filterId)
|
||||
.map((config) => config.filterId as string),
|
||||
);
|
||||
|
||||
filterIds.forEach((filterId) => {
|
||||
try {
|
||||
const filterModel = this.flowEngine?.getModel?.(filterId) as ReferenceFilterModel | undefined;
|
||||
filterModel?.context?.blockModel?.markInitialTargetRefreshHandled?.(this.uid);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _createTargetFilterManager(target: FlowModel): ReferenceFilterManager | undefined {
|
||||
const filterManager = this._getReferenceFilterManager();
|
||||
if (!filterManager) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const getTargetIdForFilterManager = (targetId: string) => (targetId === target.uid ? this.uid : targetId);
|
||||
return new Proxy(filterManager, {
|
||||
get: (source, prop) => {
|
||||
if (prop === 'prepareFiltersForTarget') {
|
||||
if (typeof source.prepareFiltersForTarget !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
return (targetId: string) => source.prepareFiltersForTarget?.(getTargetIdForFilterManager(targetId));
|
||||
}
|
||||
if (prop === 'bindToTarget') {
|
||||
if (typeof source.bindToTarget !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
return (targetId: string) => source.bindToTarget?.(getTargetIdForFilterManager(targetId));
|
||||
}
|
||||
|
||||
const value = Reflect.get(source, prop, source);
|
||||
return typeof value === 'function' ? value.bind(source) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setFilterActive(filterId: string, active: boolean) {
|
||||
super.setFilterActive(filterId, active);
|
||||
const target = this._getFilterStateTarget();
|
||||
target?.setFilterActive?.(filterId, active);
|
||||
}
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return super.hasActiveFilters() || this._getFilterStateTarget()?.hasActiveFilters?.() === true;
|
||||
}
|
||||
|
||||
removeFilterSource(filterId: string) {
|
||||
super.removeFilterSource(filterId);
|
||||
this._getFilterStateTarget()?.removeFilterSource?.(filterId);
|
||||
}
|
||||
|
||||
getDataLoadingMode(): 'auto' | 'manual' {
|
||||
return this._getFilterStateTarget()?.getDataLoadingMode?.() || super.getDataLoadingMode();
|
||||
}
|
||||
|
||||
private _restoreTemplateFallbackPatch(target?: FlowModel) {
|
||||
if (!target) return;
|
||||
const original = (target as any)[TEMPLATE_FALLBACK_PATCH_ORIGINAL_GET_STEP_PARAMS] as
|
||||
@@ -417,6 +522,10 @@ export class ReferenceBlockModel extends BlockModel {
|
||||
|
||||
const bridge = new FlowContext();
|
||||
bridge.defineProperty('engine', { value: engine });
|
||||
bridge.defineProperty('filterManager', {
|
||||
cache: false,
|
||||
get: () => this._createTargetFilterManager(target),
|
||||
});
|
||||
bridge.addDelegate(this.context as any);
|
||||
target.context.addDelegate(bridge);
|
||||
targetContext[TARGET_CONTEXT_BRIDGE_MARKER] = true;
|
||||
@@ -438,6 +547,19 @@ export class ReferenceBlockModel extends BlockModel {
|
||||
});
|
||||
}
|
||||
|
||||
private async _bindReferenceFiltersToResolvedTarget() {
|
||||
const filterManager = this._getReferenceFilterManager();
|
||||
if (!filterManager?.prepareFiltersForTarget && !filterManager?.bindToTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedFilterBlocks = await filterManager.prepareFiltersForTarget?.(this.uid);
|
||||
filterManager.bindToTarget?.(this.uid);
|
||||
preparedFilterBlocks?.forEach((filterBlock) => {
|
||||
filterBlock?.markInitialTargetRefreshHandled?.(this.uid);
|
||||
});
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this._targetModel?.title || super.title;
|
||||
}
|
||||
@@ -500,6 +622,7 @@ export class ReferenceBlockModel extends BlockModel {
|
||||
},
|
||||
});
|
||||
});
|
||||
this._markShellInitialFilterRefreshHandled();
|
||||
}
|
||||
|
||||
// 让 `ctx.model.setProps/getProps` 在引用区块场景下也作用到目标模型
|
||||
@@ -585,6 +708,7 @@ export class ReferenceBlockModel extends BlockModel {
|
||||
|
||||
public async onDispatchEventStart(eventName: string): Promise<void> {
|
||||
if (eventName !== 'beforeRender') return;
|
||||
this._markShellInitialFilterRefreshHandled();
|
||||
const stepParams = (this.getStepParams as any)?.('referenceSettings', 'target') || {};
|
||||
const targetUid = (stepParams?.targetUid || '').trim() || undefined;
|
||||
if (!targetUid) {
|
||||
@@ -674,6 +798,7 @@ export class ReferenceBlockModel extends BlockModel {
|
||||
// 关键:让 ctx.model.props.xxx 的写法在引用区块中也能作用到目标区块
|
||||
// - beforeRender 的 flows 会在 onDispatchEventStart 之后执行,因此这里同步可以保证事件流拿到的是目标 props
|
||||
this.props = target.props;
|
||||
await this._bindReferenceFiltersToResolvedTarget();
|
||||
}
|
||||
|
||||
async destroy(): Promise<boolean> {
|
||||
|
||||
+165
@@ -9,6 +9,7 @@
|
||||
|
||||
import { FlowEngine, FlowModel, MultiRecordResource, SingleRecordResource } from '@nocobase/flow-engine';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { CollectionBlockModel, FilterManager } from '@nocobase/client-v2';
|
||||
import { ReferenceBlockModel } from '../ReferenceBlockModel';
|
||||
|
||||
class MockGridModel extends FlowModel {}
|
||||
@@ -84,6 +85,28 @@ MockInteractiveBlockModel.registerEvents({
|
||||
class DetailsBlockModel extends FlowModel {}
|
||||
class EditFormModel extends FlowModel {}
|
||||
|
||||
class ReferenceFilterTargetBlockModel extends CollectionBlockModel {
|
||||
createResource(ctx: any) {
|
||||
return ctx.createResource(MultiRecordResource);
|
||||
}
|
||||
}
|
||||
|
||||
class MockFilterFormItemModel extends FlowModel {
|
||||
onInit(options: any) {
|
||||
super.onInit(options);
|
||||
this.context.defineProperty('blockModel', {
|
||||
value: options.blockModel,
|
||||
});
|
||||
this.context.defineProperty('filterField', {
|
||||
value: { name: 'nickname', type: 'string', interface: 'input' },
|
||||
});
|
||||
}
|
||||
|
||||
getFilterValue() {
|
||||
return '111';
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_TIMEOUT = 10_000;
|
||||
|
||||
describe('ReferenceBlockModel', () => {
|
||||
@@ -203,6 +226,8 @@ describe('ReferenceBlockModel', () => {
|
||||
InteractiveBlockModel: MockInteractiveBlockModel,
|
||||
DetailsBlockModel,
|
||||
EditFormModel,
|
||||
ReferenceFilterTargetBlockModel,
|
||||
MockFilterFormItemModel,
|
||||
ReferenceBlockModel,
|
||||
});
|
||||
scopedEngine.registerModels({
|
||||
@@ -213,6 +238,8 @@ describe('ReferenceBlockModel', () => {
|
||||
InteractiveBlockModel: MockInteractiveBlockModel,
|
||||
DetailsBlockModel,
|
||||
EditFormModel,
|
||||
ReferenceFilterTargetBlockModel,
|
||||
MockFilterFormItemModel,
|
||||
ReferenceBlockModel,
|
||||
});
|
||||
|
||||
@@ -452,6 +479,144 @@ describe('ReferenceBlockModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Filter linkage', () => {
|
||||
it('marks shell-target initial refresh as handled before target resolution', () => {
|
||||
const markInitialTargetRefreshHandled = vi.fn();
|
||||
engine.createModel({
|
||||
uid: 'filter-form-item-uid',
|
||||
use: 'MockFilterFormItemModel',
|
||||
blockModel: {
|
||||
prepareInitialFilterValues: vi.fn().mockResolvedValue(true),
|
||||
markInitialTargetRefreshHandled,
|
||||
},
|
||||
});
|
||||
|
||||
const filterManager = new FilterManager(gridModel as any, [
|
||||
{
|
||||
filterId: 'filter-form-item-uid',
|
||||
targetId: 'reference-block-uid',
|
||||
filterPaths: ['nickname'],
|
||||
},
|
||||
]);
|
||||
gridModel.context.defineProperty('filterManager', {
|
||||
value: filterManager,
|
||||
});
|
||||
|
||||
referenceBlockModel = engine.createModel({
|
||||
uid: 'reference-block-uid',
|
||||
use: 'ReferenceBlockModel',
|
||||
parentId: 'grid-uid',
|
||||
subKey: 'items',
|
||||
subType: 'array',
|
||||
}) as ReferenceBlockModel;
|
||||
gridModel.addSubModel('items', referenceBlockModel);
|
||||
|
||||
expect(markInitialTargetRefreshHandled).toHaveBeenCalledWith('reference-block-uid');
|
||||
});
|
||||
|
||||
it(
|
||||
'binds shell-target filters to the resolved target before its initial manual refresh',
|
||||
async () => {
|
||||
const usersCollection = {
|
||||
name: 'users',
|
||||
filterTargetKey: 'id',
|
||||
fields: [
|
||||
{ name: 'id', type: 'integer', interface: 'number' },
|
||||
{ name: 'nickname', type: 'string', interface: 'input' },
|
||||
],
|
||||
};
|
||||
engine.context.dataSourceManager.getDataSource('main').addCollection(usersCollection);
|
||||
scopedEngine.context.dataSourceManager.getDataSource('main').addCollection(usersCollection);
|
||||
|
||||
const prepareInitialFilterValues = vi.fn().mockResolvedValue(true);
|
||||
engine.createModel({
|
||||
uid: 'filter-form-item-uid',
|
||||
use: 'MockFilterFormItemModel',
|
||||
blockModel: {
|
||||
prepareInitialFilterValues,
|
||||
markInitialTargetRefreshHandled: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const filterManager = new FilterManager(gridModel as any, [
|
||||
{
|
||||
filterId: 'filter-form-item-uid',
|
||||
targetId: 'reference-block-uid',
|
||||
filterPaths: ['nickname'],
|
||||
},
|
||||
]);
|
||||
gridModel.context.defineProperty('filterManager', {
|
||||
value: filterManager,
|
||||
});
|
||||
|
||||
store['reference-filter-target-uid'] = {
|
||||
uid: 'reference-filter-target-uid',
|
||||
use: 'ReferenceFilterTargetBlockModel',
|
||||
parentId: 'grid-uid',
|
||||
subKey: 'items',
|
||||
subType: 'array',
|
||||
stepParams: {
|
||||
resourceSettings: {
|
||||
init: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
},
|
||||
dataLoadingModeSettings: {
|
||||
dataLoadingMode: {
|
||||
mode: 'manual',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
referenceBlockModel = engine.createModel({
|
||||
uid: 'reference-block-uid',
|
||||
use: 'ReferenceBlockModel',
|
||||
parentId: 'grid-uid',
|
||||
subKey: 'items',
|
||||
subType: 'array',
|
||||
stepParams: {
|
||||
referenceSettings: {
|
||||
target: {
|
||||
targetUid: 'reference-filter-target-uid',
|
||||
mode: 'reference',
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as ReferenceBlockModel;
|
||||
gridModel.addSubModel('items', referenceBlockModel);
|
||||
|
||||
await referenceBlockModel.dispatchEvent('beforeRender', undefined, { useCache: false });
|
||||
|
||||
const target = (referenceBlockModel as any)._targetModel as ReferenceFilterTargetBlockModel;
|
||||
expect(target).toBeDefined();
|
||||
expect(prepareInitialFilterValues).toHaveBeenCalledTimes(1);
|
||||
expect(target.hasActiveFilters()).toBe(true);
|
||||
expect(target.resource.getRequestParameter('filter')).toContain('111');
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
target.context.filterManager.bindToTarget(target.uid);
|
||||
const targetNotFoundCalls = consoleErrorSpy.mock.calls.filter(([message]) =>
|
||||
String(message).includes(`Target model with uid "${target.uid}" not found`),
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
expect(targetNotFoundCalls).toEqual([]);
|
||||
|
||||
const runActionSpy = vi.spyOn(target.resource, 'runAction').mockResolvedValue({
|
||||
data: [{ id: 1, nickname: '111' }],
|
||||
meta: { count: 1, hasNext: false, page: 1, pageSize: 20 },
|
||||
});
|
||||
|
||||
await target.dispatchEvent('beforeRender', undefined, { useCache: false });
|
||||
|
||||
expect(runActionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(target.resource.getData()).toEqual([{ id: 1, nickname: '111' }]);
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Template option disabled rules', () => {
|
||||
it('disables template when associationName mismatches in association context', async () => {
|
||||
referenceBlockModel = engine.createModel({
|
||||
|
||||
+50
@@ -268,6 +268,56 @@ describe('workflow > actions > workflows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('does not create a revision when autoRevision is string zero', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
type: 'collection',
|
||||
sync: true,
|
||||
config: {
|
||||
mode: 1,
|
||||
collection: 'posts',
|
||||
},
|
||||
});
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
const { body, status } = await agent.resource('workflows').execute({
|
||||
filterByTk: workflow.id,
|
||||
autoRevision: '0',
|
||||
values: {
|
||||
data: post.toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.data).not.toHaveProperty('newVersionId');
|
||||
await expect(WorkflowRepo.count({ filter: { key: workflow.key } })).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('creates a revision when autoRevision is string one', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
type: 'collection',
|
||||
sync: true,
|
||||
config: {
|
||||
mode: 1,
|
||||
collection: 'posts',
|
||||
},
|
||||
});
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
const { body, status } = await agent.resource('workflows').execute({
|
||||
filterByTk: workflow.id,
|
||||
autoRevision: '1',
|
||||
values: {
|
||||
data: post.toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.data.newVersionId).toBeDefined();
|
||||
await expect(WorkflowRepo.count({ filter: { key: workflow.key } })).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('cascading destroy all revisions, nodes, but not executions and jobs', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
|
||||
@@ -228,7 +228,7 @@ export async function execute(context: Context, next) {
|
||||
filter: { key: workflow.key },
|
||||
});
|
||||
let newVersion;
|
||||
if (executed == 0 && autoRevision) {
|
||||
if (executed === 0 && Number(autoRevision) === 1) {
|
||||
newVersion = await repository.revision({
|
||||
filterByTk: workflow.id,
|
||||
filter: { key: workflow.key },
|
||||
|
||||
Reference in New Issue
Block a user