UNPKG

@nocobase/flow-engine

Version:

A standalone flow engine for NocoBase, managing workflows, models, and actions.

426 lines (325 loc) 16.1 kB
/** * 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 { describe, it, expect } from 'vitest'; import { buildItems, buildSubModelGroups, buildSubModelItem, buildSubModelItems } from '../utils'; import { mergeSubModelItems } from '../AddSubModelButton'; import { FlowEngine } from '../../../flowEngine'; import { FlowModel } from '../../../models'; import type { FlowModelContext } from '../../../flowContext'; import type { SubModelItem } from '../AddSubModelButton'; import type { ModelConstructor } from '../../../types'; type DefineChildren = (ctx: FlowModelContext) => SubModelItem[] | Promise<SubModelItem[]>; type WithDefineChildren<T extends ModelConstructor = ModelConstructor> = T & { defineChildren: DefineChildren }; function attachDefineChildren<T extends ModelConstructor>(Base: T, def: DefineChildren): WithDefineChildren<T> { Object.defineProperty(Base, 'defineChildren', { value: def, configurable: true, writable: true, }); return Base as WithDefineChildren<T>; } describe('subModel/utils', () => { describe('buildSubModelGroups', () => { it('hides group when defineChildren resolves to empty array', async () => { const engine = new FlowEngine(); class EmptyBase extends FlowModel {} EmptyBase.define({ label: 'Empty Group' }); const EmptyBaseDC = attachDefineChildren(EmptyBase, async () => []); class NonEmptyBase extends FlowModel {} NonEmptyBase.define({ label: 'NonEmpty Group' }); const NonEmptyBaseDC = attachDefineChildren(NonEmptyBase, async () => [{ key: 'child-1', label: 'Child 1' }]); engine.registerModels({ EmptyBase: EmptyBaseDC, NonEmptyBase: NonEmptyBaseDC }); const model = engine.createModel({ use: 'FlowModel' }); const ctx = model.context; const groupsFactory = buildSubModelGroups([EmptyBaseDC, NonEmptyBaseDC]); const groups = await groupsFactory(ctx); expect(groups).toHaveLength(1); expect(groups[0].type).toBe('group'); expect(groups[0].label).toBe('NonEmpty Group'); expect(groups[0].children).toBeTruthy(); }); it('hides group when defineChildren throws', async () => { const engine = new FlowEngine(); class ThrowBase extends FlowModel {} ThrowBase.define({ label: 'Throw Group' }); const ThrowBaseDC = attachDefineChildren(ThrowBase, async () => { throw new Error('boom'); }); class OkBase extends FlowModel {} OkBase.define({ label: 'OK Group' }); const OkBaseDC = attachDefineChildren(OkBase, () => [{ key: 'ok', label: 'OK' }]); engine.registerModels({ ThrowBase: ThrowBaseDC, OkBase: OkBaseDC }); const model = engine.createModel({ use: 'FlowModel' }); const ctx = model.context; const groupsFactory = buildSubModelGroups([ThrowBaseDC, OkBaseDC]); const groups = await groupsFactory(ctx); expect(groups).toHaveLength(1); expect(groups[0].label).toBe('OK Group'); }); it('shows group when defineChildren resolves to non-empty array', async () => { const engine = new FlowEngine(); class Base extends FlowModel {} Base.define({ label: 'Base Group' }); const BaseDC = attachDefineChildren(Base, async () => [{ key: 'a', label: 'A' }]); engine.registerModels({ Base: BaseDC }); const model = engine.createModel({ use: 'FlowModel' }); const ctx = model.context; const groupsFactory = buildSubModelGroups([BaseDC]); const groups = await groupsFactory(ctx); expect(groups).toHaveLength(1); expect(groups[0].label).toBe('Base Group'); expect(groups[0].children).toBeTruthy(); }); it('invokes buildSubModelItems when meta.children is false', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class Base extends FlowModel {} Base.define({ label: 'Base', children: false }); class Derived extends Base {} Derived.define({ label: 'Derived' }); engine.registerModels({ Parent, Base, Derived }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-children-false' }); const ctx = parent.context; const groupsFactory = buildSubModelGroups([Base]); const groups = await groupsFactory(ctx); expect(groups).toHaveLength(1); const group = groups[0]; expect(Array.isArray(group.children)).toBe(true); expect((group.children as SubModelItem[]).map((item) => item.key)).toEqual(['Derived']); }); it('excludes subclasses already contributed by previous base classes', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class Root extends FlowModel {} class FirstGroup extends Root {} FirstGroup.define({ label: 'First Group', children: async () => [{ key: 'from-first', label: 'First' }] }); class SecondGroup extends Root {} class SecondLeaf extends SecondGroup {} SecondLeaf.define({ label: 'Second Leaf' }); engine.registerModels({ Parent, Root, FirstGroup, SecondGroup, SecondLeaf }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-exclude-groups' }); const ctx = parent.context; const groupsFactory = buildSubModelGroups([FirstGroup, SecondGroup]); const groups = await groupsFactory(ctx); expect(groups).toHaveLength(2); const second = groups[1]; expect(Array.isArray(second.children)).toBe(true); expect((second.children as SubModelItem[]).map((item) => item.key)).toEqual(['SecondLeaf']); }); }); describe('buildSubModelItem', () => { it('returns undefined for hidden meta entries', () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class HiddenChild extends FlowModel {} HiddenChild.define({ hide: true }); engine.registerModels({ Parent, HiddenChild }); const parent = engine.createModel({ use: 'Parent', uid: 'parent-hidden' }); const item = buildSubModelItem(HiddenChild, parent.context); expect(item).toBeUndefined(); }); it('maps meta fields and wraps children createModelOptions', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class ChildGroup extends FlowModel {} ChildGroup.define({ label: 'Child Group', searchable: true, searchPlaceholder: 'Search child', toggleable: true, createModelOptions: { stepParams: { fromMeta: true } }, children: async () => [ { key: 'leaf', label: 'Leaf', createModelOptions: async (_ctx, extra) => ({ stepParams: { fromChild: true }, extra, }), }, ], }); engine.registerModels({ Parent, ChildGroup }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-child-group' }); const ctx = parent.context; const item = buildSubModelItem(ChildGroup, ctx, false); expect(item).toBeTruthy(); expect(item?.label).toBe('Child Group'); expect(item?.searchable).toBe(true); expect(item?.searchPlaceholder).toBe('Search child'); expect(item?.toggleable).toBe(true); expect(item?.useModel).toBe('ChildGroup'); const childrenFactory = item?.children as () => Promise<SubModelItem[]>; expect(typeof childrenFactory).toBe('function'); const children = await childrenFactory(); expect(children).toHaveLength(1); const leaf = children[0]; expect(leaf.label).toBe('Leaf'); const merged = await (leaf.createModelOptions as any)?.(ctx, { fromTest: true }); expect(merged?.stepParams).toMatchObject({ fromMeta: true, fromChild: true }); expect(merged?.extra).toMatchObject({ fromTest: true }); }); it('falls back to use=current class name when meta createModelOptions omitted', () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class PlainChild extends FlowModel {} engine.registerModels({ Parent, PlainChild }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-default-create-options' }); const item = buildSubModelItem(PlainChild, parent.context); expect(item?.createModelOptions).toEqual({ use: 'PlainChild' }); }); it('still returns item when skipHide=true', () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class HiddenChild extends FlowModel {} HiddenChild.define({ hide: true, label: 'Hidden but allowed' }); engine.registerModels({ Parent, HiddenChild }); const parent = engine.createModel({ use: 'Parent', uid: 'parent-skip-hide' }); const item = buildSubModelItem(HiddenChild, parent.context, true); expect(item).toBeTruthy(); expect(item?.label).toBe('Hidden but allowed'); }); }); describe('buildSubModelItems', () => { it('sorts by meta.sort and respects exclusion list', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class Base extends FlowModel {} Base.define({ label: 'Base' }); class Early extends Base {} Early.define({ label: 'Early', sort: 10 }); class Late extends Base {} Late.define({ label: 'Late', sort: 50 }); class Hidden extends Base {} Hidden.define({ label: 'Hidden', hide: true }); engine.registerModels({ Parent, Base, Early, Late, Hidden }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-sorting' }); const ctx = parent.context; const items = await buildSubModelItems('Base')(ctx); expect(items.map((it) => it.key)).toEqual(['Early', 'Late']); const excluded = await buildSubModelItems('Base', ['Early'])(ctx); expect(excluded.map((it) => it.key)).toEqual(['Late']); }); it('excludes subclasses by constructor reference', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class Base extends FlowModel {} class KeepMe extends Base {} KeepMe.define({ label: 'Keep' }); class RemoveMe extends Base {} RemoveMe.define({ label: 'Remove' }); engine.registerModels({ Parent, Base, KeepMe, RemoveMe }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-exclude-ctor' }); const ctx = parent.context; const items = await buildSubModelItems(Base, [RemoveMe])(ctx); expect(items.map((it) => it.key)).toEqual(['KeepMe']); }); }); it('buildSubModelItems respects meta.searchable and searchPlaceholder', async () => { const engine = new FlowEngine(); class SearchableChild extends FlowModel {} // Define meta with searchable flags SearchableChild.define({ label: 'Searchable Child', searchable: true, searchPlaceholder: 'Search children' }); engine.registerModels({ SearchableChild }); const model = engine.createModel({ use: 'FlowModel' }); const ctx = model.context; // Build items from base class FlowModel so our subclass is included const itemsFactory = (await import('../utils')).buildSubModelItems('FlowModel'); const items = await itemsFactory(ctx); const found = items.find((it) => it.key === 'SearchableChild'); expect(found).toBeTruthy(); expect(found?.searchable).toBe(true); expect(found?.searchPlaceholder).toBe('Search children'); }); describe('buildSubModelGroups label and resolution', () => { it('falls back to class key when meta label missing', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class PlainGroup extends FlowModel {} const PlainGroupDC = attachDefineChildren(PlainGroup, async () => [ { key: 'leaf', label: 'Leaf', createModelOptions: { use: 'Parent' } }, ]); engine.registerModels({ Parent, PlainGroup: PlainGroupDC }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-label-fallback' }); const ctx = parent.context; const groupsFactory = buildSubModelGroups(['PlainGroup']); const groups = await groupsFactory(ctx); expect(groups).toHaveLength(1); expect(groups[0].label).toBe('PlainGroup'); }); it('buildItems unwraps first group children', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class FirstGroup extends FlowModel {} FirstGroup.define({ label: 'First Group', children: () => [ { key: 'leaf-1', label: 'Leaf 1', createModelOptions: { use: 'Parent' } }, { key: 'leaf-2', label: 'Leaf 2', createModelOptions: { use: 'Parent' } }, ], }); engine.registerModels({ Parent, FirstGroup }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-build-items' }); const ctx = parent.context; const factory = buildItems('FirstGroup'); const items = await factory(ctx); expect(items.map((it) => it.key)).toEqual(['leaf-1', 'leaf-2']); }); it('buildItems returns empty array when no groups available', async () => { const engine = new FlowEngine(); class Parent extends FlowModel {} class HiddenGroup extends FlowModel {} HiddenGroup.define({ label: 'Hidden', hide: true }); engine.registerModels({ Parent, HiddenGroup }); const parent = engine.createModel<Parent>({ use: 'Parent', uid: 'parent-empty-build-items' }); const ctx = parent.context; const items = await buildItems('HiddenGroup')(ctx); expect(items).toEqual([]); }); }); describe('mergeSubModelItems', () => { it('merges multiple arrays and inserts dividers when requested', async () => { const sourceA: SubModelItem[] = [{ key: 'a', label: 'A' }]; const sourceB: SubModelItem[] = [{ key: 'b', label: 'B' }]; const merged = mergeSubModelItems([sourceA, sourceB], { addDividers: true }); const ctx = {} as FlowModelContext; const result = Array.isArray(merged) ? merged : await merged(ctx); expect(result.map((i) => i.key)).toEqual(['a', 'divider-1', 'b']); expect(result[1]?.type).toBe('divider'); }); it('flattens nested factories while preserving order', async () => { const dynamic = async () => [{ key: 'dynamic', label: 'Dynamic' }]; const factory = mergeSubModelItems([[{ key: 'a', label: 'A' }], dynamic, async () => [{ key: 'c', label: 'C' }]]); const ctx = {} as FlowModelContext; const items = await (factory as (ctx: FlowModelContext) => Promise<SubModelItem[]>)(ctx); expect(items.map((i) => i.key)).toEqual(['a', 'dynamic', 'c']); }); it('awaits async sources while inserting dividers', async () => { const factory = mergeSubModelItems( [async () => [{ key: 'first', label: 'First' }], async () => [{ key: 'second', label: 'Second' }]], { addDividers: true }, ); const ctx = {} as FlowModelContext; const items = await (factory as (ctx: FlowModelContext) => Promise<SubModelItem[]>)(ctx); expect(items.map((i) => i.key)).toEqual(['first', 'divider-1', 'second']); expect(items[1]?.type).toBe('divider'); }); it('returns empty array when sources are empty or null', async () => { const merged = mergeSubModelItems([undefined, null, []]); const ctx = {} as FlowModelContext; const result = Array.isArray(merged) ? merged : await merged(ctx); expect(result).toEqual([]); }); it('returns original source when only one valid entry', () => { const original: SubModelItem[] = [{ key: 'solo', label: 'Solo' }]; const merged = mergeSubModelItems([null, original, undefined]); expect(merged).toBe(original); }); }); });