create-procode 0.0.0-stage → 1.0.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.
- package/README.md +240 -2
- package/bin/create-procode.mjs +9 -0
- package/package.json +30 -4
- package/src/args.mjs +139 -0
- package/src/cli.mjs +392 -0
- package/src/deps.mjs +195 -0
- package/src/log.mjs +23 -0
- package/src/prompt.mjs +60 -0
- package/src/scaffold.mjs +104 -0
- package/templates/base/README.md.tmpl +183 -0
- package/templates/base/_dotenv.local.example.tmpl +8 -0
- package/templates/base/_dotenv.tmpl +41 -0
- package/templates/base/_doteslintrc.cjs.tmpl +125 -0
- package/templates/base/_dotgitignore.tmpl +32 -0
- package/templates/base/_dotnvmrc.tmpl +1 -0
- package/templates/base/_dotvscode/extensions.json +3 -0
- package/templates/base/index.html.tmpl +18 -0
- package/templates/base/package.json.tmpl +15 -0
- package/templates/base/public/favicon.svg +4 -0
- package/templates/base/src/AdapterProvider/AdapterFactory.ts.tmpl +21 -0
- package/templates/base/src/AdapterProvider/SampleContacts.ts.tmpl +136 -0
- package/templates/base/src/CustomAction.ts.tmpl +47 -0
- package/templates/base/src/CustomAuthorizationProvider.ts.tmpl +77 -0
- package/templates/base/src/Factory/Actions/myAction.ts.tmpl +51 -0
- package/templates/base/src/Factory/Actions/sampleContacts.ts.tmpl +244 -0
- package/templates/base/src/Factory/Actions/setField.ts.tmpl +20 -0
- package/templates/base/src/Factory/CustomRuleEffectFactory.ts.tmpl +30 -0
- package/templates/base/src/Factory/CustomValidationFactory.tsx.tmpl +24 -0
- package/templates/base/src/Factory/CustomWidgetFactory.tsx.tmpl +43 -0
- package/templates/base/src/Factory/RuleEffects/readOnlyEffect.ts.tmpl +27 -0
- package/templates/base/src/Factory/Validations/myValidation.ts.tmpl +52 -0
- package/templates/base/src/Factory/Widgets/MyWidget/index.tsx.tmpl +101 -0
- package/templates/base/src/Factory/Widgets/Notification/index.tsx.tmpl +53 -0
- package/templates/base/src/Factory/Widgets/Notification/notifiFunc.ts.tmpl +9 -0
- package/templates/base/src/Factory/Widgets/SectionNav/index.tsx +193 -0
- package/templates/base/src/Factory/Widgets/SectionNav/style.scss +139 -0
- package/templates/base/src/Module/Sample/Contacts.json.tmpl +965 -0
- package/templates/base/src/Module/index.ts.tmpl +26 -0
- package/templates/base/src/Screens/index.tsx.tmpl +45 -0
- package/templates/base/src/Utils/errorMessages.ts.tmpl +35 -0
- package/templates/base/src/Utils/fieldErrorMapper.ts.tmpl +46 -0
- package/templates/base/src/Utils/logger.ts.tmpl +21 -0
- package/templates/base/src/Utils/responseFormatter.ts.tmpl +39 -0
- package/templates/base/src/Utils/storages.ts.tmpl +35 -0
- package/templates/base/src/assets/styles/index.scss.tmpl +146 -0
- package/templates/base/src/constant.ts.tmpl +53 -0
- package/templates/base/src/icons.ts.tmpl +17 -0
- package/templates/base/src/localizationResolver.ts.tmpl +48 -0
- package/templates/base/src/main.tsx.tmpl +136 -0
- package/templates/base/src/routes.ts.tmpl +20 -0
- package/templates/base/src/vite-env.d.ts.tmpl +20 -0
- package/templates/base/tsconfig.json.tmpl +30 -0
- package/templates/base/tsconfig.node.json.tmpl +11 -0
- package/templates/base/vite.config.ts.tmpl +61 -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;
|