create-procode 0.0.0-stage → 1.1.0

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.
Files changed (68) hide show
  1. package/README.md +266 -2
  2. package/bin/create-procode.mjs +9 -0
  3. package/package.json +30 -4
  4. package/src/args.mjs +145 -0
  5. package/src/cli.mjs +514 -0
  6. package/src/deps.mjs +208 -0
  7. package/src/log.mjs +23 -0
  8. package/src/multiLanguage.mjs +409 -0
  9. package/src/prompt.mjs +60 -0
  10. package/src/scaffold.mjs +104 -0
  11. package/templates/base/README.md.tmpl +185 -0
  12. package/templates/base/_dotenv.local.example.tmpl +8 -0
  13. package/templates/base/_dotenv.tmpl +41 -0
  14. package/templates/base/_doteslintrc.cjs.tmpl +125 -0
  15. package/templates/base/_dotgitignore.tmpl +32 -0
  16. package/templates/base/_dotnvmrc.tmpl +1 -0
  17. package/templates/base/_dotvscode/extensions.json +3 -0
  18. package/templates/base/index.html.tmpl +18 -0
  19. package/templates/base/package.json.tmpl +15 -0
  20. package/templates/base/public/favicon.svg +4 -0
  21. package/templates/base/src/AdapterProvider/AdapterFactory.ts.tmpl +21 -0
  22. package/templates/base/src/AdapterProvider/SampleContacts.ts.tmpl +136 -0
  23. package/templates/base/src/CustomAction.ts.tmpl +47 -0
  24. package/templates/base/src/CustomAuthorizationProvider.ts.tmpl +77 -0
  25. package/templates/base/src/Factory/Actions/myAction.ts.tmpl +51 -0
  26. package/templates/base/src/Factory/Actions/sampleContacts.ts.tmpl +244 -0
  27. package/templates/base/src/Factory/Actions/setField.ts.tmpl +20 -0
  28. package/templates/base/src/Factory/CustomRuleEffectFactory.ts.tmpl +30 -0
  29. package/templates/base/src/Factory/CustomValidationFactory.tsx.tmpl +24 -0
  30. package/templates/base/src/Factory/CustomWidgetFactory.tsx.tmpl +43 -0
  31. package/templates/base/src/Factory/RuleEffects/readOnlyEffect.ts.tmpl +27 -0
  32. package/templates/base/src/Factory/Validations/myValidation.ts.tmpl +52 -0
  33. package/templates/base/src/Factory/Widgets/MyWidget/index.tsx.tmpl +101 -0
  34. package/templates/base/src/Factory/Widgets/Notification/index.tsx.tmpl +53 -0
  35. package/templates/base/src/Factory/Widgets/Notification/notifiFunc.ts.tmpl +9 -0
  36. package/templates/base/src/Factory/Widgets/SectionNav/index.tsx +193 -0
  37. package/templates/base/src/Factory/Widgets/SectionNav/style.scss +139 -0
  38. package/templates/base/src/Module/Sample/Contacts.json.tmpl +965 -0
  39. package/templates/base/src/Module/index.ts.tmpl +26 -0
  40. package/templates/base/src/Screens/index.tsx.tmpl +45 -0
  41. package/templates/base/src/Utils/errorMessages.ts.tmpl +35 -0
  42. package/templates/base/src/Utils/fieldErrorMapper.ts.tmpl +46 -0
  43. package/templates/base/src/Utils/logger.ts.tmpl +21 -0
  44. package/templates/base/src/Utils/responseFormatter.ts.tmpl +39 -0
  45. package/templates/base/src/Utils/storages.ts.tmpl +35 -0
  46. package/templates/base/src/assets/styles/index.scss.tmpl +149 -0
  47. package/templates/base/src/constant.ts.tmpl +53 -0
  48. package/templates/base/src/icons.ts.tmpl +17 -0
  49. package/templates/base/src/localizationResolver.ts.tmpl +48 -0
  50. package/templates/base/src/main.tsx.tmpl +136 -0
  51. package/templates/base/src/routes.ts.tmpl +20 -0
  52. package/templates/base/src/vite-env.d.ts.tmpl +20 -0
  53. package/templates/base/tsconfig.json.tmpl +30 -0
  54. package/templates/base/tsconfig.node.json.tmpl +11 -0
  55. package/templates/base/vite.config.ts.tmpl +90 -0
  56. package/templates/multi-language/locales/fr/core.json +76 -0
  57. package/templates/multi-language/locales/fr/kendo.json +181 -0
  58. package/templates/multi-language/locales/fr/template.json +169 -0
  59. package/templates/multi-language/locales/fr/widget.json +65 -0
  60. package/templates/multi-language/locales/hi/core.json +76 -0
  61. package/templates/multi-language/locales/hi/kendo.json +181 -0
  62. package/templates/multi-language/locales/hi/template.json +169 -0
  63. package/templates/multi-language/locales/hi/widget.json +65 -0
  64. package/templates/multi-language/sample/fr.json +55 -0
  65. package/templates/multi-language/sample/hi.json +55 -0
  66. package/templates/tests/src/Module/schemas.test.ts +50 -0
  67. package/templates/tests-multi-language/src/multiLanguage/enText.ts +68 -0
  68. package/templates/tests-multi-language/src/multiLanguage/translations.test.ts +92 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The adapter for the sample "Contacts" screen (screenId "sample-contacts").
3
+ *
4
+ * An adapter is a PURE TRANSLATOR between the API's shape and the screen's
5
+ * shape. It must not issue HTTP and must not hold UI state.
6
+ * Every hook is optional — implement only the ones the screen needs:
7
+ *
8
+ * defaultViewModel initial { screenData, supportiveData }
9
+ * deserializedModelData API response -> UI shape
10
+ * deserializedSupportiveData lookup/dropdown data -> UI shape
11
+ * keyValueMappinig API data -> label/value pairs (sic: the
12
+ * framework spells it this way)
13
+ * serializedRequestParam UI state -> API request body
14
+ * generateQueryParams filter/sort/paging -> query string
15
+ *
16
+ * THIS SAMPLE HAS NO BACKEND. `defaultViewModel` seeds the rows the grid shows
17
+ * (the core replaces its empty seed with whatever this returns), so the screen renders and every button works without an API.
18
+ * Going live means: declare `apiConfig` in the schema, bind LOAD_SCREEN_DATA,
19
+ * and let `deserializedModelData` below shape the response — the seed rows
20
+ * simply stop being used.
21
+ */
22
+
23
+ export type Contact = {
24
+ id: number;
25
+ name: string;
26
+ email: string;
27
+ department: string;
28
+ city: string;
29
+ joined: string;
30
+ status: "Active" | "Inactive";
31
+ /** Rendered by the grid as HTML — every grid cell is an HTML sink. */
32
+ statusBadge: string;
33
+ };
34
+
35
+ /** A status pill the grid can render — theme badge classes, no literal colours. */
36
+ export const statusBadge = (status: Contact["status"]) =>
37
+ `<span class="qo-badge qo-badge-${status === "Active" ? "success" : "default"} qo-badge-sm qo-badge-fixed">${status}</span>`;
38
+
39
+ /** Totals for the summary tiles. Recomputed by the sample actions after every change. */
40
+ export const summarise = (records: Contact[]) => ({
41
+ total: records.length,
42
+ active: records.filter((r) => r.status === "Active").length,
43
+ departments: new Set(records.map((r) => r.department)).size,
44
+ });
45
+
46
+ const seed: Omit<Contact, "statusBadge">[] = [
47
+ { id: 1, name: "Ava Thompson", email: "ava.thompson@example.com", department: "Sales", city: "Austin", joined: "2023-02-14", status: "Active" },
48
+ { id: 2, name: "Liam Patel", email: "liam.patel@example.com", department: "Engineering", city: "Denver", joined: "2022-09-01", status: "Active" },
49
+ { id: 3, name: "Sofia Martinez", email: "sofia.martinez@example.com", department: "Support", city: "Miami", joined: "2024-01-22", status: "Inactive" },
50
+ { id: 4, name: "Noah Kim", email: "noah.kim@example.com", department: "Finance", city: "Seattle", joined: "2021-06-30", status: "Active" },
51
+ { id: 5, name: "Emma Rossi", email: "emma.rossi@example.com", department: "Sales", city: "Chicago", joined: "2023-11-05", status: "Active" },
52
+ { id: 6, name: "Oliver Nguyen", email: "oliver.nguyen@example.com", department: "Engineering", city: "Boston", joined: "2020-03-18", status: "Inactive" },
53
+ { id: 7, name: "Mia Johansson", email: "mia.johansson@example.com", department: "Support", city: "Portland", joined: "2024-05-09", status: "Active" },
54
+ { id: 8, name: "Ethan Okafor", email: "ethan.okafor@example.com", department: "Finance", city: "Atlanta", joined: "2022-12-12", status: "Active" },
55
+ ];
56
+
57
+ /** The editor's empty state. `mode` drives the SHOW rule on the form ("" = hidden, "add" | "edit"). */
58
+ export const emptyEditor = () => ({
59
+ mode: "",
60
+ id: null as number | null,
61
+ name: "",
62
+ email: "",
63
+ department: null as { label: string; value: string } | null,
64
+ joined: null as string | null,
65
+ active: true,
66
+ notes: "",
67
+ region: null as { label: string; value: string } | null,
68
+ referenceCode: "",
69
+ });
70
+
71
+ export class SampleContacts {
72
+ /** Shapes the screen before any data arrives. Every field a widget binds to
73
+ * should exist here, or the first render reads undefined. */
74
+ defaultViewModel = (_props?: any) => {
75
+ const records: Contact[] = seed.map((r) => ({ ...r, statusBadge: statusBadge(r.status) }));
76
+ return {
77
+ screenData: {
78
+ // The full set the sample's stand-in "API" serves from. With a real
79
+ // backend this key does not exist — the server holds the data.
80
+ contactsSource: records,
81
+ // The GRID reads { records, total } — that shape is mandatory.
82
+ // The grid's onScreenDataLoad event fills it (CUS_SAMPLE_LOAD_ROWS, or
83
+ // LOAD_SCREEN_DATA once there is an API); seeded here so the first
84
+ // paint is never empty.
85
+ contacts: { records, total: records.length },
86
+ summary: summarise(records),
87
+ editor: emptyEditor(),
88
+ // Written by the grid's bulk actions and read by the alert widgets'
89
+ // SHOW rules.
90
+ deleteConfirm: null,
91
+ statusConfirm: null,
92
+ saveNotice: null,
93
+ },
94
+ supportiveData: null,
95
+ };
96
+ };
97
+
98
+ /** API response -> the shape the schema's screenDataField paths expect.
99
+ * Used once `apiConfig.get` exists; the seed above is ignored from then on. */
100
+ deserializedModelData = (response: any) => {
101
+ const rows: any[] = response?.data?.records ?? response?.records ?? response?.data ?? [];
102
+ const records: Contact[] = rows.map((r) => ({
103
+ id: r.id,
104
+ name: r.name ?? "",
105
+ email: r.email ?? "",
106
+ department: r.department ?? "",
107
+ city: r.city ?? "",
108
+ joined: r.joined ?? "",
109
+ status: r.status === "Inactive" ? "Inactive" : "Active",
110
+ statusBadge: statusBadge(r.status === "Inactive" ? "Inactive" : "Active"),
111
+ }));
112
+ return {
113
+ contacts: { records, total: response?.data?.total ?? response?.total ?? records.length },
114
+ summary: summarise(records),
115
+ editor: emptyEditor(),
116
+ };
117
+ };
118
+
119
+ /** Screen state -> the request body for FORMSUBMIT_* / CREATE_ASYNC. The
120
+ * editor holds the DROPDOWN's whole { label, value } object,
121
+ * so the scalar is extracted here, where the API shape belongs. */
122
+ serializedRequestParam = (viewModel: any) => {
123
+ const e = viewModel?.screenData?.editor ?? {};
124
+ return {
125
+ id: e.id,
126
+ name: e.name,
127
+ email: e.email,
128
+ department: e.department?.value ?? null,
129
+ region: e.region?.value ?? null,
130
+ joined: e.joined,
131
+ status: e.active ? "Active" : "Inactive",
132
+ notes: e.notes,
133
+ referenceCode: e.referenceCode,
134
+ };
135
+ };
136
+ }
@@ -0,0 +1,47 @@
1
+ import { setField } from "./Factory/Actions/setField";
2
+ import { myAction, suggestReferenceCode } from "./Factory/Actions/myAction";
3
+ import { loadRows, openRow, updateRows, saveContact, confirmBody, noticeBody } from "./Factory/Actions/sampleContacts";
4
+
5
+ /**
6
+ * Extension point 4 of 5: custom actions, resolved by a CUS_-prefixed key.
7
+ *
8
+ * Resolution is custom -> template -> standard. A key no tier knows is logged
9
+ * and toasted as "This action is not available" — a missing entry here is the
10
+ * first thing to check. A Map, not an object literal: on a plain object,
11
+ * actionMap["constructor"] returns a function the core would invoke.
12
+ *
13
+ * Write a custom action only for logic none of the standard keys expresses,
14
+ * and NEVER to make an HTTP call. The standard keys —
15
+ * NAVIGATE, NAVIGATE_BACK, MODIFY_MODEL, SET_VALUES, LOAD_SCREEN_DATA,
16
+ * LOAD_SUPPORTIVE_DATA, FORMSUBMIT_UPDATE_ASYNC, FORMSUBMIT_CREATE_ASYNC,
17
+ * CREATE_ASYNC, UPDATE_ASYNC, DELETE_ASYNC, GET_ASYNC
18
+ * plus vs-template's MENUNAVIGATE — cover all normal data access.
19
+ *
20
+ * Template widgets call app actions too: GRIDTOOLBAR's getUniqueViewId
21
+ * (its callBack must be called synchronously), MENU's loadMenuDetails, and the
22
+ * alert boxes' handleHeader / handleBody. Register those keys here as well.
23
+ *
24
+ * The CUS_SAMPLE_* keys stand in for DELETE_ASYNC / UPDATE_ASYNC /
25
+ * FORMSUBMIT_CREATE_ASYNC because this sample has no backend; each says so in
26
+ * its header. CUS_SET_FIELD is the one worth keeping: it lets a button reach
27
+ * MODIFY_MODEL with a config-declared value.
28
+ */
29
+ class CustomActionFactory {
30
+ private actionMap = new Map<string, (props: any, ...args: any[]) => any>([
31
+ ["CUS_SET_FIELD", setField],
32
+ ["CUS_MY_ACTION", myAction],
33
+ ["CUS_SUGGEST_CODE", suggestReferenceCode],
34
+ ["CUS_SAMPLE_LOAD_ROWS", loadRows],
35
+ ["CUS_SAMPLE_OPEN_ROW", openRow],
36
+ ["CUS_SAMPLE_UPDATE_ROWS", updateRows],
37
+ ["CUS_SAMPLE_SAVE", saveContact],
38
+ ["CUS_SAMPLE_CONFIRM_BODY", confirmBody],
39
+ ["CUS_SAMPLE_NOTICE_BODY", noticeBody],
40
+ ]);
41
+
42
+ getAction(key: string) {
43
+ return this.actionMap.get(key) ?? null; // never throw: this runs outside the core's try/catch
44
+ }
45
+ }
46
+
47
+ export default CustomActionFactory;
@@ -0,0 +1,77 @@
1
+ import {
2
+ AuthzContext,
3
+ AuthzKind,
4
+ AuthzResult,
5
+ IAuthorizationProvider,
6
+ } from "procode-lowcode-core";
7
+
8
+ /**
9
+ * The core ships no authorization provider. When you pass none, the getter
10
+ * returns undefined and every gate reads that as "authorization disabled" —
11
+ * fail-open. Passing one gates three kinds:
12
+ *
13
+ * AuthzKind.SCREEN keyed by the screen's slug. Denied => every widget locked
14
+ * in skeleton mode, your message as a toast, no data fetch.
15
+ * AuthzKind.WIDGET keyed by `permissionKey` on the element. Denied => the
16
+ * widget is hidden SILENTLY; the message is ignored.
17
+ * AuthzKind.ACTION keyed by `permissionKey` on the event. Denied => the click
18
+ * is blocked and your message is toasted.
19
+ *
20
+ * Gate every surface that shows the same records. A list screen with a grid and
21
+ * a card view behind a toggle needs one key on both — gating only the grid
22
+ * leaves the card view as an open bypass.
23
+ */
24
+ export default class CustomAuthorizationProvider implements IAuthorizationProvider {
25
+ // Default to GRANTED while wiring up, so the screen behaves exactly as it did
26
+ // before the keys existed. The gates are then provable without taking
27
+ // anything away from anyone until the real fetch below says so.
28
+ private perms = { add: true, grid: true, save: true };
29
+
30
+ /**
31
+ * Called ONCE per screen change, before any widget renders, and AWAITED — the
32
+ * core holds the screen in PENDING (skeleton) until it settles. That is what
33
+ * stops a denied widget from flashing on screen before the answer arrives, so
34
+ * the real permission fetch must live here and nowhere else.
35
+ *
36
+ * A rejection here is fail-CLOSED for the screen. If a lookup failure should
37
+ * NOT deny, catch it and decide deliberately.
38
+ */
39
+ async loadPermissions(context: AuthzContext): Promise<void> {
40
+ if (context.slug === "sample") {
41
+ // const res = await fetchPermissions(context.slug);
42
+ // this.perms = { add: res.add, grid: res.grid, save: res.save };
43
+ // Flip any of these to false and reload to SEE the gate work: the grid
44
+ // disappears, or the button click is refused with the message below.
45
+ this.perms = { add: true, grid: true, save: true };
46
+ }
47
+ }
48
+
49
+ isAuthorized(kind: AuthzKind, permissionKey: string, context: AuthzContext): AuthzResult {
50
+ if (kind === AuthzKind.SCREEN) {
51
+ // Deny a whole screen here — e.g. `context.slug === "admin" && !this.perms.admin`
52
+ // — and every widget on it stays in skeleton mode with your message toasted.
53
+ void context;
54
+ return { authorized: true }; // omit `message` to use the core's default
55
+ }
56
+
57
+ // WIDGET and ACTION are both keyed by the schema-authored permissionKey.
58
+ // Denied widgets are hidden silently, so no message is worth setting.
59
+ if (permissionKey === "sample.grid") {
60
+ return { authorized: this.perms.grid };
61
+ }
62
+
63
+ // A denied action DOES toast.
64
+ if (permissionKey === "sample.add") {
65
+ return this.perms.add
66
+ ? { authorized: true }
67
+ : { authorized: false, message: "Adding contacts isn't allowed for your role." };
68
+ }
69
+ if (permissionKey === "sample.save") {
70
+ return this.perms.save
71
+ ? { authorized: true }
72
+ : { authorized: false, message: "Saving contacts isn't allowed for your role." };
73
+ }
74
+
75
+ return { authorized: true }; // un-keyed elements are always allowed
76
+ }
77
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Two custom actions that back the sample's custom widget (MY_WIDGET).
3
+ *
4
+ * Signature: (props, ...eventArgs). For an `onChange` the event args are
5
+ * (fieldPath, newValue, event); for an `onClick` there are usually none, unless
6
+ * the widget chooses to pass some — MyWidget passes its field path on reload.
7
+ *
8
+ * `props` carries:
9
+ * actionProps { key, config } — config is whatever you authored in the
10
+ * schema under the event.
11
+ * callBack The next action in the chain, or the framework's default
12
+ * continuation. Call it to hand off.
13
+ * viewModel The current { screenData, supportiveData }. READ-ONLY —
14
+ * write through callBack or the store.
15
+ * navigate React Router's NavigateFunction.
16
+ * eventService The per-screen event bus. emit("DATASTATE_ACTION") /
17
+ * emit("DISPATCH_STORE") reach the store directly.
18
+ * executeValidation Run a field's validations on demand; returns
19
+ * { isComponentValid, invalidMessages }.
20
+ * uiElementGroupId Which element group fired the event.
21
+ *
22
+ * Bound and chained from a schema as:
23
+ *
24
+ * "events": {
25
+ * "onChange": { "key": "CUS_MY_ACTION::MODIFY_MODEL" },
26
+ * "onReload": { "key": "CUS_SUGGEST_CODE::MODIFY_MODEL" }
27
+ * }
28
+ *
29
+ * `::` chains actions left to right; each step's callBack is the next step.
30
+ */
31
+
32
+ /** CUS_MY_ACTION — normalise the typed value, then hand it to MODIFY_MODEL. */
33
+ export const myAction = (props: any, fieldPath: string, newValue: any) => {
34
+ const cleaned = String(newValue ?? "").trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
35
+
36
+ // Hand the value to the next action in the chain. When this action is chained
37
+ // as CUS_MY_ACTION::MODIFY_MODEL, callBack IS the MODIFY_MODEL step — so pass
38
+ // the REAL field path. Passing "screenData" instead makes MODIFY_MODEL
39
+ // validate a field name that does not exist, and the field's own error
40
+ // never clears.
41
+ props?.callBack?.(null, fieldPath, cleaned);
42
+ };
43
+
44
+ /** CUS_SUGGEST_CODE — a computed value for a field. MyWidget raises
45
+ * onReload(null, fieldPath), so the path arrives as the first event arg. */
46
+ export const suggestReferenceCode = (props: any, fieldPath?: string) => {
47
+ const field = fieldPath ?? props?.actionProps?.config?.field;
48
+ if (!field) return;
49
+ const code = `CT_${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
50
+ props?.callBack?.(null, field, code);
51
+ };
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The custom actions behind the sample "Contacts" screen.
3
+ *
4
+ * Before adding one of your own: a custom action is for COMPUTATION and
5
+ * ORCHESTRATION between standard steps. Writing a value is MODIFY_MODEL, loading is LOAD_SCREEN_DATA, saving
6
+ * is FORMSUBMIT_CREATE_ASYNC, confirming is CONFOMATIONALERTBOX, navigating is
7
+ * NAVIGATE — none of those needs a CUS_ key.
8
+ *
9
+ * This sample has no backend, so a few of these stand in for a standard action
10
+ * that would otherwise talk to an API. Each such stand-in says so in its header
11
+ * and names the standard key that replaces it when the screen goes live.
12
+ *
13
+ * Every handler has the signature (props, ...eventArgs) and hands its result
14
+ * to the next chain step through `props.callBack(null, field, value)`. Where a
15
+ * handler needs to write more than one path at once it dispatches through the
16
+ * event bus instead, which is the documented host-side write.
17
+ */
18
+
19
+ import { summarise, statusBadge, emptyEditor, type Contact } from "../../AdapterProvider/SampleContacts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // CUS_SAMPLE_LOAD_ROWS — the grid asked for data.
23
+ //
24
+ // SAMPLE STAND-IN for LOAD_SCREEN_DATA + apiConfig.get.
25
+ // The grid raises `onScreenDataLoad(resultHandler)` on mount and again whenever
26
+ // its sort, column search or pagination state changes (data-load mode B);
27
+ // `resultHandler` arrives as `props.callBack` and writes what it is given to
28
+ // the grid's screenDataField. A server would read the same grid state from the
29
+ // request the adapter's generateQueryParams builds; here it is read straight
30
+ // off the view model and applied to the in-memory set: sort by
31
+ // `state.sort[0]`, filter by every `localSearch.appliedQuery` row ("contains",
32
+ // case-insensitive — the only operator the column search emits).
33
+ // Paging is left to the server on purpose: the grid re-slices locally what a
34
+ // mode-B load returns, so a real page would be sliced twice.
35
+ // ---------------------------------------------------------------------------
36
+ export const loadRows = (props: any) => {
37
+ const screenData = props?.viewModel?.screenData ?? {};
38
+ const state = screenData[GRID_STATE_KEY] ?? {};
39
+ props?.callBack?.(project(screenData.contactsSource ?? [], state));
40
+ };
41
+
42
+ const GRID_STATE_KEY = "contactsGridState"; // = the grid's config.uiElementGroupId, minus "screenData."
43
+
44
+ /** Apply the grid's sort and column-search state to the full set. */
45
+ export const project = (source: Contact[], state: any): { records: Contact[]; total: number } => {
46
+ const filters: any[] = state?.localSearch?.appliedQuery?.filters ?? [];
47
+ let records = source.filter((row: any) =>
48
+ filters.every((f) => {
49
+ const needle = String(f?.value ?? "").trim().toLowerCase();
50
+ return !needle || String(row?.[f.field] ?? "").toLowerCase().includes(needle);
51
+ }),
52
+ );
53
+ const sort = state?.sort?.[0];
54
+ if (sort?.field) {
55
+ const dir = sort.dir === "desc" ? -1 : 1;
56
+ records = [...records].sort((a: any, b: any) =>
57
+ String(a?.[sort.field] ?? "").localeCompare(String(b?.[sort.field] ?? ""), undefined, { numeric: true }) * dir,
58
+ );
59
+ }
60
+ return { records, total: records.length };
61
+ };
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // CUS_SAMPLE_OPEN_ROW — a grid row was activated: load it into the editor.
65
+ //
66
+ // The GRID raises `navigate` with { dataItemKey, value }. With a real edit
67
+ // screen this event is NAVIGATE with a STATE_SELECTED_DATA param (the
68
+ // PromptManager pattern); this sample edits inline, so the
69
+ // row is looked up and handed to MODIFY_MODEL.
70
+ // ---------------------------------------------------------------------------
71
+ export const openRow = (props: any, selected?: { dataItemKey: string; value: any }) => {
72
+ const records: Contact[] = props?.viewModel?.screenData?.contactsSource ?? [];
73
+ const key = selected?.dataItemKey ?? "id";
74
+ const row = records.find((r: any) => r?.[key] === selected?.value);
75
+ if (!row) return;
76
+ props?.callBack?.(null, "screenData.editor", {
77
+ ...emptyEditor(),
78
+ mode: "edit",
79
+ id: row.id,
80
+ name: row.name,
81
+ email: row.email,
82
+ department: { label: row.department, value: row.department },
83
+ // The DATE widget binds a Date object (or null) — a string reaches Kendo's
84
+ // calendar and throws `date.getTime is not a function`. With
85
+ // an API this conversion belongs in the adapter's deserializedModelData.
86
+ joined: row.joined ? new Date(row.joined) : null,
87
+ active: row.status === "Active",
88
+ });
89
+ };
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // CUS_SAMPLE_UPDATE_ROWS — a bulk action was CONFIRMED for the selected rows.
93
+ //
94
+ // SAMPLE STAND-IN. With an API these events are DELETE_ASYNC / UPDATE_ASYNC
95
+ // with their own apiConfig verb block and a `refresh: true` on the action —
96
+ // the framework then re-fetches the grid.
97
+ // Here the rows are updated in memory from the event config:
98
+ // { "remove": true } delete
99
+ // { "set": { "status": "Inactive" } } patch
100
+ //
101
+ // Invoked by the grid as (props, selectedData) with `props.callBack` bound to
102
+ // the grid's own "clear the selection" continuation, so the write goes through
103
+ // the bus and the callback is honoured afterwards.
104
+ // ---------------------------------------------------------------------------
105
+ export const updateRows = (props: any, selectedData: any[] = []) => {
106
+ const config = props?.actionProps?.config ?? {};
107
+ const screenData = props?.viewModel?.screenData ?? {};
108
+ const source: Contact[] = screenData.contactsSource ?? [];
109
+ const ids = new Set(selectedData.map((r: any) => r?.id));
110
+
111
+ const next = config.remove
112
+ ? source.filter((r) => !ids.has(r.id))
113
+ : source.map((r) => {
114
+ if (!ids.has(r.id)) return r;
115
+ const patched = { ...r, ...(config.set ?? {}) } as Contact;
116
+ return { ...patched, statusBadge: statusBadge(patched.status) };
117
+ });
118
+
119
+ writeScreenData(props, {
120
+ ...screenData,
121
+ contactsSource: next,
122
+ contacts: project(next, screenData[GRID_STATE_KEY]),
123
+ summary: summarise(next),
124
+ });
125
+ props?.callBack?.();
126
+ };
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // CUS_SAMPLE_SAVE — validate the editor with the FRAMEWORK'S validators, then
130
+ // upsert the row and show the success dialog.
131
+ //
132
+ // SAMPLE STAND-IN. With an API the Save button is FORMSUBMIT_CREATE_ASYNC /
133
+ // FORMSUBMIT_UPDATE_ASYNC: it validates every visible
134
+ // widget, POSTs `serializedRequestParam`, toasts per responseConfig and
135
+ // navigates — and this whole action is deleted. Nothing here invents a
136
+ // validator: each editor field's own `validations` are run through the
137
+ // `executeValidation` seam the core hands every action, and the
138
+ // messages land in the same store slice the standard path writes.
139
+ //
140
+ // Chained as "CUS_SAMPLE_SAVE::MODIFY_MODEL": on failure the chain stops at the
141
+ // field errors; on success the next step receives the whole screenData.
142
+ // ---------------------------------------------------------------------------
143
+ export const saveContact = (props: any) => {
144
+ const screenData = props?.viewModel?.screenData ?? {};
145
+ const editor = screenData.editor ?? {};
146
+
147
+ const errors = validateWidgetsUnder(props, "screenData.editor.");
148
+ dispatch(props, "loadValidation", errors);
149
+ if (Object.values(errors).some((m) => m.length > 0)) return;
150
+
151
+ const source: Contact[] = screenData.contactsSource ?? [];
152
+ const status: Contact["status"] = editor.active ? "Active" : "Inactive";
153
+ const row: Contact = {
154
+ id: editor.id ?? Math.max(0, ...source.map((r) => r.id)) + 1,
155
+ name: editor.name,
156
+ email: editor.email,
157
+ department: editor.department?.value ?? "",
158
+ city: source.find((r) => r.id === editor.id)?.city ?? "—",
159
+ joined: typeof editor.joined === "string" ? editor.joined : toIsoDate(editor.joined),
160
+ status,
161
+ statusBadge: statusBadge(status),
162
+ };
163
+ const next = editor.id
164
+ ? source.map((r) => (r.id === row.id ? row : r))
165
+ : [...source, row];
166
+
167
+ props?.callBack?.(null, "screenData", {
168
+ ...screenData,
169
+ contactsSource: next,
170
+ contacts: project(next, screenData[GRID_STATE_KEY]),
171
+ summary: summarise(next),
172
+ editor: emptyEditor(),
173
+ saveNotice: {
174
+ visibleMode: "SHOW",
175
+ message: `${row.name} was ${editor.id ? "updated" : "added"}. With an API this is FORMSUBMIT_CREATE_ASYNC and a responseConfig toast.`,
176
+ },
177
+ });
178
+ };
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // CUS_SAMPLE_CONFIRM_BODY — put the selection count into a dialog's body.
182
+ //
183
+ // The alert widgets raise `handleBody(setBody, messageBody, value)`. Every widget event has the shape (callBack, ...args), so the action
184
+ // receives it as (props, messageBody, value) with `props.callBack` = setBody —
185
+ // the same convention as every other event, and the same pattern as
186
+ // vsbackoffice's CUS_GETCONFIRMATIONBODYMSG. `value` is what the grid wrote to
187
+ // the alert's screenDataField: it carries `selectedData`.
188
+ // ---------------------------------------------------------------------------
189
+ export const confirmBody = (props: any, body?: string, value?: any) => {
190
+ const count = value?.selectedData?.length ?? 0;
191
+ props?.callBack?.(String(body ?? "").replace("{count}", String(count)).replace("{s}", count === 1 ? "" : "s"));
192
+ };
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // CUS_SAMPLE_NOTICE_BODY — the success dialog's text comes from what
196
+ // CUS_SAMPLE_SAVE wrote to screenData.saveNotice.message.
197
+ // ---------------------------------------------------------------------------
198
+ export const noticeBody = (props: any, body?: string, value?: any) => {
199
+ props?.callBack?.(String(body ?? "").replace("{message}", String(value?.message ?? "")));
200
+ };
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // helpers — the two documented host-side seams.
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** dispatch(appActions[name](payload)) through the event bus. */
207
+ const dispatch = (props: any, name: string, payload: any) => {
208
+ const appActions = props?.eventService?.emit("DATASTATE_ACTION");
209
+ const store = props?.eventService?.emit("DISPATCH_STORE");
210
+ const creator = appActions?.[name];
211
+ if (typeof creator === "function" && typeof store === "function") store(creator(payload));
212
+ };
213
+
214
+ const writeScreenData = (props: any, value: any) => dispatch(props, "updateViewModel", { key: "screenData", value });
215
+
216
+ /** Run each widget's own `validations` (ONSUBMIT) for every visible WIDGET
217
+ * bound under `prefix`, exactly as FORMSUBMIT_* does before it posts. */
218
+ const validateWidgetsUnder = (props: any, prefix: string): Record<string, string[]> => {
219
+ const uiSchema = props?.eventService?.emit("UISCHEMA");
220
+ const errors: Record<string, string[]> = {};
221
+ const walk = (el: any) => {
222
+ if (!el) return;
223
+ if (el.uiElementType === "WIDGET") {
224
+ if (el.visible !== false && el.screenDataField?.startsWith(prefix) && el.validations?.length) {
225
+ const result = props?.executeValidation?.({
226
+ executeMode: "ONSUBMIT",
227
+ screenDataField: el.screenDataField,
228
+ validations: el.validations,
229
+ });
230
+ errors[el.screenDataField] = result?.invalidMessages ?? [];
231
+ }
232
+ return;
233
+ }
234
+ (el.cells ?? []).forEach(walk);
235
+ (el.children ?? []).forEach(walk);
236
+ };
237
+ walk(uiSchema?.rootScreenElement);
238
+ return errors;
239
+ };
240
+
241
+ const toIsoDate = (value: any) => {
242
+ const d = value instanceof Date ? value : value ? new Date(value) : null;
243
+ return d && !Number.isNaN(d.getTime()) ? d.toISOString().slice(0, 10) : "";
244
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * CUS_SET_FIELD — write a config-declared value to a config-declared path.
3
+ *
4
+ * Chained as "CUS_SET_FIELD::MODIFY_MODEL". A BUTTON raises onClick with no
5
+ * arguments, and MODIFY_MODEL takes (field, value) as EVENT ARGUMENTS, so a
6
+ * button cannot reach MODIFY_MODEL on its own; this one-liner turns the event
7
+ * config into those arguments. It is the smallest legitimate custom action
8
+ * (a payload shaped from config and forwarded through callBack)
9
+ * and the one worth keeping in every app — "set X when clicked".
10
+ *
11
+ * "onClick": {
12
+ * "key": "CUS_SET_FIELD::MODIFY_MODEL",
13
+ * "config": { "field": "screenData.editor", "value": { "mode": "add" } }
14
+ * }
15
+ */
16
+ export const setField = (props: any) => {
17
+ const { field, value } = props?.actionProps?.config ?? {};
18
+ if (!field) return;
19
+ props?.callBack?.(null, field, value);
20
+ };
@@ -0,0 +1,30 @@
1
+ import type { IRuleEffectFactory, RuleEffectFc } from "procode-lowcode-core";
2
+ import { RuleEffectTypes } from "../constant";
3
+ import { readOnlyEffect } from "./RuleEffects/readOnlyEffect";
4
+
5
+ /**
6
+ * Custom rule effects, resolved by the `effect` name of a ruleInterpreter rule.
7
+ *
8
+ * Resolution is custom -> template -> standard, and only a function counts, so
9
+ * returning null hands the key on to the next tier. The standard tier owns
10
+ * SHOW, HIDE, ENABLE_ELEMENT, DISABLE_ELEMENT, ENABLE_REQUIRED,
11
+ * DISABLE_REQUIRED, ENABLE_VALIDATION, DISABLE_VALIDATION, RESET_VALUE,
12
+ * COMPUTE_VALUE, CHANGE_LABEL, TOGGLE_CLASS and SET_PROPS. Registering one of
13
+ * those names here OVERRIDES it on every screen — e.g.
14
+ * `[EffectType.SHOW, myShow]` with EffectType from procode-lowcode-core.
15
+ *
16
+ * An unknown effect is skipped with one warning per key. A Map, not an object
17
+ * literal: `effects["constructor"]` on a plain object returns a function the
18
+ * engine would call.
19
+ */
20
+ class CustomRuleEffectFactory implements IRuleEffectFactory {
21
+ private effects = new Map<string, RuleEffectFc>([
22
+ [RuleEffectTypes.CUS_READONLY, readOnlyEffect],
23
+ ]);
24
+
25
+ getEffect(key: string) {
26
+ return this.effects.get(key) ?? null; // never throw
27
+ }
28
+ }
29
+
30
+ export default CustomRuleEffectFactory;
@@ -0,0 +1,24 @@
1
+ import { IValidationFactory } from "procode-lowcode-core";
2
+ import { ValidationTypes } from "../constant";
3
+ import { myValidation } from "./Validations/myValidation";
4
+
5
+ /**
6
+ * Extension point 5 of 5: custom validations, resolved by ValidationTypes
7
+ * constant. Resolution is custom -> template -> standard; a name no tier knows
8
+ * is logged and the field counts as VALID. A validation that "never fires" is
9
+ * usually a name that does not match the schema's `validations[].name`.
10
+ *
11
+ * A validation fn gets ({ validation, viewModel, eventService, value }) and
12
+ * returns { isValid, message }. It must not throw — a throw aborts the submit.
13
+ */
14
+ class CustomValidationFactory implements IValidationFactory {
15
+ private validationMap = new Map<string, (props: any) => { isValid: boolean; message: string }>([
16
+ [ValidationTypes.MY_VALIDATION, myValidation],
17
+ ]);
18
+
19
+ getValidationFc(key: string) {
20
+ return this.validationMap.get(key) ?? null; // never throw
21
+ }
22
+ }
23
+
24
+ export default CustomValidationFactory;