@nocobase/flow-engine 2.1.0-beta.35 → 2.1.0-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import React from 'react';
10
10
  import { FlowContext, FlowEngineContext } from './flowContext';
11
- import { FlowView } from './views/FlowView';
11
+ import { FlowView, FlowViewer } from './views/FlowView';
12
12
  export declare const FlowReactContext: React.Context<FlowContext>;
13
13
  export declare const FlowViewContext: React.Context<FlowContext>;
14
14
  export declare function FlowContextProvider(props: {
@@ -22,3 +22,7 @@ export declare const FlowViewContextProvider: React.MemoExoticComponent<(props:
22
22
  export declare function useFlowContext<T = FlowEngineContext>(): T;
23
23
  export declare function useFlowViewContext<T = FlowEngineContext>(): T;
24
24
  export declare function useFlowView(): FlowView;
25
+ /**
26
+ * Access the `FlowViewer` that opens new drawers / modals / pages (`viewer.drawer({...})`, `viewer.modal({...})`, etc.). This is the counterpart to `useFlowView()`: `useFlowView()` returns the *current* mounted view (use it to close yourself, render Header/Footer slots, etc.), while `useFlowViewer()` returns the surface that lets you open a *new* view from inside any flow-context subtree.
27
+ */
28
+ export declare function useFlowViewer(): FlowViewer;
@@ -43,7 +43,8 @@ __export(FlowContextProvider_exports, {
43
43
  FlowViewContextProvider: () => FlowViewContextProvider,
44
44
  useFlowContext: () => useFlowContext,
45
45
  useFlowView: () => useFlowView,
46
- useFlowViewContext: () => useFlowViewContext
46
+ useFlowViewContext: () => useFlowViewContext,
47
+ useFlowViewer: () => useFlowViewer
47
48
  });
48
49
  module.exports = __toCommonJS(FlowContextProvider_exports);
49
50
  var import_react = __toESM(require("react"));
@@ -70,6 +71,11 @@ function useFlowView() {
70
71
  return ctx.view;
71
72
  }
72
73
  __name(useFlowView, "useFlowView");
74
+ function useFlowViewer() {
75
+ const ctx = useFlowContext();
76
+ return ctx.viewer;
77
+ }
78
+ __name(useFlowViewer, "useFlowViewer");
73
79
  // Annotate the CommonJS export names for ESM import in node:
74
80
  0 && (module.exports = {
75
81
  FlowContextProvider,
@@ -78,5 +84,6 @@ __name(useFlowView, "useFlowView");
78
84
  FlowViewContextProvider,
79
85
  useFlowContext,
80
86
  useFlowView,
81
- useFlowViewContext
87
+ useFlowViewContext,
88
+ useFlowViewer
82
89
  });
@@ -260,6 +260,21 @@ const normalizeOpenKeys = /* @__PURE__ */ __name((nextOpenKeys) => {
260
260
  }
261
261
  return nextOpenKeys.filter((key) => latestKey === key || latestKey.startsWith(`${key}/`));
262
262
  }, "normalizeOpenKeys");
263
+ const getLabelSearchText = /* @__PURE__ */ __name((label) => {
264
+ if (label === null || label === void 0 || typeof label === "boolean") {
265
+ return "";
266
+ }
267
+ if (typeof label === "string" || typeof label === "number") {
268
+ return String(label);
269
+ }
270
+ if (Array.isArray(label)) {
271
+ return label.map(getLabelSearchText).join(" ");
272
+ }
273
+ if (import_react.default.isValidElement(label)) {
274
+ return getLabelSearchText(label.props.children);
275
+ }
276
+ return "";
277
+ }, "getLabelSearchText");
263
278
  const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchValue, menuVisible, t, updateSearchValue) => ({
264
279
  key: `${searchKey}-search`,
265
280
  type: "group",
@@ -378,13 +393,9 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
378
393
  const currentSearchValue = searchValues[searchKey] || "";
379
394
  const filteredChildren = currentSearchValue ? (/* @__PURE__ */ __name(function deepFilter(items2) {
380
395
  const searchText = currentSearchValue.toLowerCase();
381
- const tryString = /* @__PURE__ */ __name((v) => {
382
- if (!v) return "";
383
- return typeof v === "string" ? v : String(v);
384
- }, "tryString");
385
396
  return items2.map((child) => {
386
- const labelStr = tryString(child.label).toLowerCase();
387
- const selfMatch = labelStr.includes(searchText) || child.key && String(child.key).toLowerCase().includes(searchText);
397
+ const labelStr = getLabelSearchText(child.label).toLowerCase();
398
+ const selfMatch = labelStr.includes(searchText);
388
399
  if (child.type === "group" && Array.isArray(child.children)) {
389
400
  const nested = deepFilter(child.children);
390
401
  if (selfMatch || nested.length > 0) {
@@ -172,7 +172,7 @@ function buildSubModelItems(subModelBaseClass, exclude = []) {
172
172
  __name(buildSubModelItems, "buildSubModelItems");
173
173
  function buildSubModelGroups(subModelBaseClasses = []) {
174
174
  return async (ctx) => {
175
- var _a, _b, _c;
175
+ var _a, _b, _c, _d, _e;
176
176
  const items = [];
177
177
  const exclude = [];
178
178
  for (const subModelBaseClass of subModelBaseClasses) {
@@ -203,11 +203,15 @@ function buildSubModelGroups(subModelBaseClasses = []) {
203
203
  const baseKey = typeof subModelBaseClass === "string" ? subModelBaseClass : BaseClass.name;
204
204
  const menuType = ((_b = BaseClass == null ? void 0 : BaseClass.meta) == null ? void 0 : _b.menuType) || "group";
205
205
  const groupSort = ((_c = BaseClass == null ? void 0 : BaseClass.meta) == null ? void 0 : _c.sort) ?? 1e3;
206
+ const searchable = !!((_d = BaseClass == null ? void 0 : BaseClass.meta) == null ? void 0 : _d.searchable);
207
+ const searchPlaceholder = (_e = BaseClass == null ? void 0 : BaseClass.meta) == null ? void 0 : _e.searchPlaceholder;
206
208
  if (menuType === "submenu") {
207
209
  items.push({
208
210
  key: baseKey,
209
211
  label: groupLabel,
210
212
  sort: groupSort,
213
+ searchable,
214
+ searchPlaceholder,
211
215
  children
212
216
  });
213
217
  } else {
@@ -216,6 +220,8 @@ function buildSubModelGroups(subModelBaseClasses = []) {
216
220
  type: "group",
217
221
  label: groupLabel,
218
222
  sort: groupSort,
223
+ searchable,
224
+ searchPlaceholder,
219
225
  children
220
226
  });
221
227
  }
@@ -978,7 +978,7 @@ const _CollectionField = class _CollectionField {
978
978
  {
979
979
  ...import_lodash.default.omit(((_a = this.options.uiSchema) == null ? void 0 : _a["x-component-props"]) || {}, "fieldNames"),
980
980
  options: this.enum.length ? this.enum : void 0,
981
- mode: this.type === "array" ? "multiple" : void 0,
981
+ mode: this.interface === "multipleSelect" ? "multiple" : void 0,
982
982
  multiple: target ? ["belongsToMany", "hasMany", "belongsToArray"].includes(type) : void 0,
983
983
  maxCount: target && !["belongsToMany", "hasMany", "belongsToArray"].includes(type) ? 1 : void 0,
984
984
  target,
@@ -29,3 +29,4 @@ export { createEphemeralContext } from './createEphemeralContext';
29
29
  export { pruneFilter } from './pruneFilter';
30
30
  export { isBeforeRenderFlow } from './flows';
31
31
  export { resolveModuleUrl, isCssFile } from './resolveModuleUrl';
32
+ export { randomId } from './randomId';
@@ -74,6 +74,7 @@ __export(utils_exports, {
74
74
  prepareRunJsCode: () => import_runjsTemplateCompat.prepareRunJsCode,
75
75
  preprocessRunJsTemplates: () => import_runjsTemplateCompat.preprocessRunJsTemplates,
76
76
  pruneFilter: () => import_pruneFilter.pruneFilter,
77
+ randomId: () => import_randomId.randomId,
77
78
  resolveCreateModelOptions: () => import_params_resolvers.resolveCreateModelOptions,
78
79
  resolveCtxDatePath: () => import_dateVariable.resolveCtxDatePath,
79
80
  resolveDefaultParams: () => import_params_resolvers.resolveDefaultParams,
@@ -115,6 +116,7 @@ var import_createEphemeralContext = require("./createEphemeralContext");
115
116
  var import_pruneFilter = require("./pruneFilter");
116
117
  var import_flows = require("./flows");
117
118
  var import_resolveModuleUrl = require("./resolveModuleUrl");
119
+ var import_randomId = require("./randomId");
118
120
  // Annotate the CommonJS export names for ESM import in node:
119
121
  0 && (module.exports = {
120
122
  BLOCK_GROUP_CONFIGS,
@@ -165,6 +167,7 @@ var import_resolveModuleUrl = require("./resolveModuleUrl");
165
167
  prepareRunJsCode,
166
168
  preprocessRunJsTemplates,
167
169
  pruneFilter,
170
+ randomId,
168
171
  resolveCreateModelOptions,
169
172
  resolveCtxDatePath,
170
173
  resolveDefaultParams,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ /**
10
+ * Generate a random base36 identifier with an optional semantic prefix.
11
+ *
12
+ * Equivalent in shape to v1's `uid()` from `@formily/shared` (11 chars
13
+ * of `[0-9a-z]`), with an opt-in prefix appended at the front. v2 forbids
14
+ * direct `@formily/*` imports in `src/client-v2/`, so this helper is the
15
+ * single substitute the rest of the codebase should reach for.
16
+ *
17
+ * Common semantic prefixes observed across the codebase — pass the one
18
+ * that matches your domain rather than relying on a default, so the
19
+ * intent is explicit at the call site:
20
+ *
21
+ * - `s_` — service / settings record (authenticators, channels, …)
22
+ * - `v_` — verifier / variable / LLM service
23
+ * - `f_` — field
24
+ * - `t_` — through table
25
+ *
26
+ * Example:
27
+ *
28
+ * ```ts
29
+ * import { randomId } from '@nocobase/flow-engine';
30
+ *
31
+ * name: randomId('s_'), // → 's_keeoaui1ubi'
32
+ * name: randomId('v_'), // → 'v_a8f3kp2x9qm'
33
+ * name: randomId(), // → 'a8f3kp2x9qm'
34
+ * ```
35
+ *
36
+ * Not cryptographically secure — uses `Math.random()`. Good enough for
37
+ * unique form names / schema keys, NOT for security tokens.
38
+ */
39
+ export declare function randomId(prefix?: string, length?: number): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var randomId_exports = {};
29
+ __export(randomId_exports, {
30
+ randomId: () => randomId
31
+ });
32
+ module.exports = __toCommonJS(randomId_exports);
33
+ const CHARSET = "0123456789abcdefghijklmnopqrstuvwxyz";
34
+ function randomId(prefix = "", length = 11) {
35
+ let id = "";
36
+ for (let i = 0; i < length; i++) {
37
+ id += CHARSET[Math.random() * CHARSET.length | 0];
38
+ }
39
+ return `${prefix}${id}`;
40
+ }
41
+ __name(randomId, "randomId");
42
+ // Annotate the CommonJS export names for ESM import in node:
43
+ 0 && (module.exports = {
44
+ randomId
45
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-beta.35",
3
+ "version": "2.1.0-beta.37",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-beta.35",
12
- "@nocobase/shared": "2.1.0-beta.35",
11
+ "@nocobase/sdk": "2.1.0-beta.37",
12
+ "@nocobase/shared": "2.1.0-beta.37",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "74310d8b9e9581fcde14b5a93d12b41ddb5bb325"
40
+ "gitHead": "7132e5b83ecc0e42b54715eaf1429c72bcef34ae"
41
41
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React, { createContext, useContext } from 'react';
11
11
  import { FlowContext, FlowEngineContext } from './flowContext';
12
- import { FlowView } from './views/FlowView';
12
+ import { FlowView, FlowViewer } from './views/FlowView';
13
13
 
14
14
  export const FlowReactContext = createContext<FlowContext>(null);
15
15
  export const FlowViewContext = createContext<FlowContext>(null);
@@ -40,3 +40,11 @@ export function useFlowView() {
40
40
  const ctx = useFlowContext();
41
41
  return ctx.view as FlowView;
42
42
  }
43
+
44
+ /**
45
+ * Access the `FlowViewer` that opens new drawers / modals / pages (`viewer.drawer({...})`, `viewer.modal({...})`, etc.). This is the counterpart to `useFlowView()`: `useFlowView()` returns the *current* mounted view (use it to close yourself, render Header/Footer slots, etc.), while `useFlowViewer()` returns the surface that lets you open a *new* view from inside any flow-context subtree.
46
+ */
47
+ export function useFlowViewer() {
48
+ const ctx = useFlowContext();
49
+ return ctx.viewer as FlowViewer;
50
+ }
@@ -368,6 +368,22 @@ const normalizeOpenKeys = (nextOpenKeys: string[]) => {
368
368
  return nextOpenKeys.filter((key) => latestKey === key || latestKey.startsWith(`${key}/`));
369
369
  };
370
370
 
371
+ const getLabelSearchText = (label: React.ReactNode): string => {
372
+ if (label === null || label === undefined || typeof label === 'boolean') {
373
+ return '';
374
+ }
375
+ if (typeof label === 'string' || typeof label === 'number') {
376
+ return String(label);
377
+ }
378
+ if (Array.isArray(label)) {
379
+ return label.map(getLabelSearchText).join(' ');
380
+ }
381
+ if (React.isValidElement(label)) {
382
+ return getLabelSearchText(label.props.children);
383
+ }
384
+ return '';
385
+ };
386
+
371
387
  const createSearchItem = (
372
388
  item: Item,
373
389
  searchKey: string,
@@ -532,15 +548,10 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
532
548
  const filteredChildren = currentSearchValue
533
549
  ? (function deepFilter(items: Item[]): Item[] {
534
550
  const searchText = currentSearchValue.toLowerCase();
535
- const tryString = (v: any) => {
536
- if (!v) return '';
537
- return typeof v === 'string' ? v : String(v);
538
- };
539
551
  return items
540
552
  .map((child) => {
541
- const labelStr = tryString(child.label).toLowerCase();
542
- const selfMatch =
543
- labelStr.includes(searchText) || (child.key && String(child.key).toLowerCase().includes(searchText));
553
+ const labelStr = getLabelSearchText(child.label).toLowerCase();
554
+ const selfMatch = labelStr.includes(searchText);
544
555
  if (child.type === 'group' && Array.isArray(child.children)) {
545
556
  const nested = deepFilter(child.children);
546
557
  if (selfMatch || nested.length > 0) {
@@ -310,6 +310,50 @@ describe('transformItems - searchable flags', () => {
310
310
  expect(submenu.searchPlaceholder).toBe('Search blocks');
311
311
  expect(Array.isArray(submenu.children)).toBe(true);
312
312
  });
313
+
314
+ it('filters searchable field menus by display label instead of item key', async () => {
315
+ const engine = new FlowEngine();
316
+ await engine.flowSettings.forceEnable();
317
+ const parent = engine.createModel<FlowModel>({ use: FlowModel });
318
+ const user = userEvent.setup();
319
+
320
+ const items = [
321
+ {
322
+ key: 'fields',
323
+ label: '',
324
+ type: 'group' as const,
325
+ searchable: true,
326
+ searchPlaceholder: 'Search fields',
327
+ children: [
328
+ { key: 'field_name', label: 'Field display name' },
329
+ { key: 'other_field', label: 'Other field' },
330
+ ],
331
+ },
332
+ ];
333
+
334
+ render(
335
+ <FlowEngineProvider engine={engine}>
336
+ <ConfigProvider>
337
+ <App>
338
+ <AddSubModelButton model={parent} items={items as any} subModelKey="items">
339
+ Open
340
+ </AddSubModelButton>
341
+ </App>
342
+ </ConfigProvider>
343
+ </FlowEngineProvider>,
344
+ );
345
+
346
+ await user.click(screen.getByText('Open'));
347
+ const searchInput = await screen.findByPlaceholderText('Search fields');
348
+ expect(screen.getByText('Field display name')).toBeInTheDocument();
349
+
350
+ await user.type(searchInput, 'field_name');
351
+ await waitFor(() => expect(screen.queryByText('Field display name')).not.toBeInTheDocument());
352
+
353
+ await user.clear(searchInput);
354
+ await user.type(searchInput, 'display');
355
+ await waitFor(() => expect(screen.getByText('Field display name')).toBeInTheDocument());
356
+ });
313
357
  });
314
358
 
315
359
  describe('transformItems - hide', () => {
@@ -100,6 +100,30 @@ describe('subModel/utils', () => {
100
100
  expect(groups[0].children).toBeTruthy();
101
101
  });
102
102
 
103
+ it('preserves searchable meta on generated groups', async () => {
104
+ const engine = new FlowEngine();
105
+
106
+ class Base extends FlowModel {}
107
+ Base.define({
108
+ label: 'Base Group',
109
+ searchable: true,
110
+ searchPlaceholder: 'Search fields',
111
+ });
112
+ const BaseDC = attachDefineChildren(Base, async () => [{ key: 'title', label: 'Title' }]);
113
+
114
+ engine.registerModels({ Base: BaseDC });
115
+
116
+ const model = engine.createModel({ use: 'FlowModel' });
117
+ const ctx = model.context;
118
+
119
+ const groupsFactory = buildSubModelGroups([BaseDC]);
120
+ const groups = await groupsFactory(ctx);
121
+
122
+ expect(groups).toHaveLength(1);
123
+ expect(groups[0].searchable).toBe(true);
124
+ expect(groups[0].searchPlaceholder).toBe('Search fields');
125
+ });
126
+
103
127
  it('invokes buildSubModelItems when meta.children is false', async () => {
104
128
  const engine = new FlowEngine();
105
129
 
@@ -196,12 +196,16 @@ export function buildSubModelGroups(subModelBaseClasses: (string | ModelConstruc
196
196
  const baseKey = typeof subModelBaseClass === 'string' ? subModelBaseClass : BaseClass.name;
197
197
  const menuType = BaseClass?.meta?.menuType || 'group';
198
198
  const groupSort = BaseClass?.meta?.sort ?? 1000;
199
+ const searchable = !!BaseClass?.meta?.searchable;
200
+ const searchPlaceholder = BaseClass?.meta?.searchPlaceholder;
199
201
  if (menuType === 'submenu') {
200
202
  // 作为可点击的一级项,展开二级子菜单
201
203
  items.push({
202
204
  key: baseKey,
203
205
  label: groupLabel,
204
206
  sort: groupSort,
207
+ searchable,
208
+ searchPlaceholder,
205
209
  children,
206
210
  });
207
211
  } else {
@@ -211,6 +215,8 @@ export function buildSubModelGroups(subModelBaseClasses: (string | ModelConstruc
211
215
  type: 'group',
212
216
  label: groupLabel,
213
217
  sort: groupSort,
218
+ searchable,
219
+ searchPlaceholder,
214
220
  children,
215
221
  });
216
222
  }
@@ -1114,7 +1114,7 @@ export class CollectionField {
1114
1114
  {
1115
1115
  ..._.omit(this.options.uiSchema?.['x-component-props'] || {}, 'fieldNames'),
1116
1116
  options: this.enum.length ? this.enum : undefined,
1117
- mode: this.type === 'array' ? 'multiple' : undefined,
1117
+ mode: this.interface === 'multipleSelect' ? 'multiple' : undefined,
1118
1118
  multiple: target ? ['belongsToMany', 'hasMany', 'belongsToArray'].includes(type) : undefined,
1119
1119
  maxCount: target && !['belongsToMany', 'hasMany', 'belongsToArray'].includes(type) ? 1 : undefined,
1120
1120
  target: target,
@@ -104,3 +104,6 @@ export { isBeforeRenderFlow } from './flows';
104
104
 
105
105
  // Module URL resolver
106
106
  export { resolveModuleUrl, isCssFile } from './resolveModuleUrl';
107
+
108
+ // Random base36 identifier with optional semantic prefix
109
+ export { randomId } from './randomId';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyz';
11
+
12
+ /**
13
+ * Generate a random base36 identifier with an optional semantic prefix.
14
+ *
15
+ * Equivalent in shape to v1's `uid()` from `@formily/shared` (11 chars
16
+ * of `[0-9a-z]`), with an opt-in prefix appended at the front. v2 forbids
17
+ * direct `@formily/*` imports in `src/client-v2/`, so this helper is the
18
+ * single substitute the rest of the codebase should reach for.
19
+ *
20
+ * Common semantic prefixes observed across the codebase — pass the one
21
+ * that matches your domain rather than relying on a default, so the
22
+ * intent is explicit at the call site:
23
+ *
24
+ * - `s_` — service / settings record (authenticators, channels, …)
25
+ * - `v_` — verifier / variable / LLM service
26
+ * - `f_` — field
27
+ * - `t_` — through table
28
+ *
29
+ * Example:
30
+ *
31
+ * ```ts
32
+ * import { randomId } from '@nocobase/flow-engine';
33
+ *
34
+ * name: randomId('s_'), // → 's_keeoaui1ubi'
35
+ * name: randomId('v_'), // → 'v_a8f3kp2x9qm'
36
+ * name: randomId(), // → 'a8f3kp2x9qm'
37
+ * ```
38
+ *
39
+ * Not cryptographically secure — uses `Math.random()`. Good enough for
40
+ * unique form names / schema keys, NOT for security tokens.
41
+ */
42
+ export function randomId(prefix = '', length = 11): string {
43
+ let id = '';
44
+ for (let i = 0; i < length; i++) {
45
+ id += CHARSET[(Math.random() * CHARSET.length) | 0];
46
+ }
47
+ return `${prefix}${id}`;
48
+ }