@dforge-core/dforge-mcp 0.2.5 → 0.2.14
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/CHANGELOG.md +102 -0
- package/dist/server.js +128 -4
- package/package.json +4 -4
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +40 -0
- package/resources/schemas/manifest.schema.json +6 -0
- package/resources/schemas/reports.schema.json +41 -17
- package/skills/dforge-mcp-author/references/action-dsl.md +4 -1
- package/skills/dforge-mcp-author/references/manifest.md +29 -0
- package/skills/dforge-mcp-author/references/reports.md +103 -17
- package/skills/dforge-mcp-author/references/validation-checklist.md +13 -0
- package/skills/dforge-module-build/SKILL.md +29 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,108 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
|
|
|
4
4
|
`0.1.0-rc.N` pre-release tags; the published version is set at publish time via
|
|
5
5
|
the release workflow, so committed `package.json` versions are placeholders.
|
|
6
6
|
|
|
7
|
+
## 0.2.6
|
|
8
|
+
|
|
9
|
+
Record reports — a report attached to an entity so it opens **from a record**, with the
|
|
10
|
+
record's values feeding its parameters — plus a report-level home for report parameters,
|
|
11
|
+
and the removal of two parameter spellings the installer never read.
|
|
12
|
+
|
|
13
|
+
Requires `@dforge-core/metadata` ≥ 0.0.15 (bumped): the vendored
|
|
14
|
+
`resources/schemas/reports.schema.json` served as `dforge://schema/reports` comes from
|
|
15
|
+
there.
|
|
16
|
+
|
|
17
|
+
### Added — record-report attachments
|
|
18
|
+
|
|
19
|
+
A report in `ui/reports.json` gains an `entities` array. Each entry attaches the report to
|
|
20
|
+
one entity, mapping report parameters to source columns on it:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
"credit_check": {
|
|
24
|
+
"entities": [
|
|
25
|
+
{ "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 },
|
|
26
|
+
{ "entityCd": "crm.quote", "params": { "customer_id": "customer_id" }, "orderNum": 45 }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The report then appears on that record's toolbar and opens on a record-scoped route with
|
|
32
|
+
the mapped params resolved server-side and hidden. `dforge_report_add` accepts and
|
|
33
|
+
documents the block; `references/reports.md` gains a **Record reports** section with the
|
|
34
|
+
rules (source-column allowlist, type compatibility, one attachment per (entity, report)
|
|
35
|
+
pair, the `"metadata": ">=1.5.0"` gate, and the cross-module dependency bound).
|
|
36
|
+
|
|
37
|
+
Note the two different `params` keys, which the tool description now calls out: on an
|
|
38
|
+
attachment it is the *mapping* (param code → column); on the report or a dataset it is the
|
|
39
|
+
*declaration*.
|
|
40
|
+
|
|
41
|
+
### Added — `dforge_module_validate` checks reports
|
|
42
|
+
|
|
43
|
+
Mirrors the server's new pack-time `ReportAttachmentValidator`, so these fail offline
|
|
44
|
+
instead of at install against a live tenant:
|
|
45
|
+
|
|
46
|
+
- a mapped param code neither the report-level `parameters` block nor any dataset declares;
|
|
47
|
+
- a source column the entity doesn't have, or one that's a set / formula / free-text column;
|
|
48
|
+
- two attachments to the same entity in one report (the second overwrites the first);
|
|
49
|
+
- an attachment to a cross-module entity whose module isn't a declared dependency;
|
|
50
|
+
- `isRequired`, a top-level `link`, or `fieldTypeCd` + `domain` together on a param;
|
|
51
|
+
- a warning when a module ships attachments with no `metadata` dependency.
|
|
52
|
+
|
|
53
|
+
### Added — report-level `parameters`
|
|
54
|
+
|
|
55
|
+
Report parameters are **report-scoped**: the declaration is stored once per report
|
|
56
|
+
(`report.param_set_id` → `param_set`), the authoring API is `report.params.save`, and
|
|
57
|
+
`report.get` flattens per-dataset defaults report-wide before use. The installer had only
|
|
58
|
+
ever read the per-dataset shorthand, so a report-level block was silently dropped and the
|
|
59
|
+
report installed with no parameters at all — which is what made
|
|
60
|
+
`crm-fin.customer_statement` and `wms-fin.vendor_statement` ship parameterless.
|
|
61
|
+
|
|
62
|
+
Both declaration sites are now legal and mean the same thing; the installer merges them,
|
|
63
|
+
**report level winning** on a code collision:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
"customer_statement": {
|
|
67
|
+
"parameters": {
|
|
68
|
+
"customer_id": { "label": "Customer", "fieldTypeCd": "lookup",
|
|
69
|
+
"params": { "link": { "entity": "parties.party" } }, "required": true }
|
|
70
|
+
},
|
|
71
|
+
"datasets": { "statement_invoices": { }, "statement_payments": { } }
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Declare at report level when several datasets use the parameter — `customer_statement` is
|
|
76
|
+
exactly that case, and there is no meaningful dataset to attribute it to.
|
|
77
|
+
`datasets.<cd>.params` stays the right shorthand for a single-dataset parameter, and every
|
|
78
|
+
module written before this keeps working.
|
|
79
|
+
|
|
80
|
+
`dforge_module_validate` merges both sites when resolving a record-report mapping, so a
|
|
81
|
+
mapping onto a report-level parameter resolves exactly as install will resolve it.
|
|
82
|
+
|
|
83
|
+
### Fixed — two report-param spellings that are silently ignored
|
|
84
|
+
|
|
85
|
+
Unlike `parameters`, these are simply misspellings of keys that already exist, so they are
|
|
86
|
+
gone from the schema rather than implemented — and the validator flags them at either
|
|
87
|
+
declaration site:
|
|
88
|
+
|
|
89
|
+
- **`isRequired`** — the key is `required`; the parameter installed as optional.
|
|
90
|
+
- **top-level `link`** on a lookup parameter — it belongs under `params`; the parameter
|
|
91
|
+
installed with no autocomplete.
|
|
92
|
+
|
|
93
|
+
`references/reports.md` also corrects the rights-key example from `report.<code>` to the
|
|
94
|
+
colon form `report:<code>`, and the lookup parameter config from `params.entityCd` to
|
|
95
|
+
`params.link.entity`.
|
|
96
|
+
|
|
97
|
+
### Changed — steer calculation toward reports, not the DSL
|
|
98
|
+
|
|
99
|
+
`action-dsl.md`'s *When an action is the wrong tool* now names the record report as the
|
|
100
|
+
direct replacement for a `check_…` action — same toolbar entry point, but the user sees
|
|
101
|
+
the open invoices and the ageing rather than just "FAILED" — and adds the general rule:
|
|
102
|
+
**do not compute in the DSL what a report dataset already aggregates.** Sums, ratios,
|
|
103
|
+
ageing buckets and exposure get formatting, drill-through, row caps and live re-reads for
|
|
104
|
+
free from a report; DSL arithmetic over `select()` reproduces that badly, once, somewhere
|
|
105
|
+
nothing else can reuse. Write DSL when the number must be *written down* or must *stop*
|
|
106
|
+
something. The same steer is in `dsl-reference.md`, Phase 2a and Phase 3c of the
|
|
107
|
+
`dforge-module-build` skill, and the validation checklist.
|
|
108
|
+
|
|
7
109
|
## 0.2.5
|
|
8
110
|
|
|
9
111
|
Catches up with the platform's parameter-options work (dForge-core 1.20.0). A dropdown
|
package/dist/server.js
CHANGED
|
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
|
|
|
31002
31002
|
var fs2 = __toESM(require("fs"));
|
|
31003
31003
|
var path2 = __toESM(require("path"));
|
|
31004
31004
|
|
|
31005
|
-
// node_modules/.pnpm/@dforge-core+dforge-cli@0.2.
|
|
31005
|
+
// node_modules/.pnpm/@dforge-core+dforge-cli@0.2.14/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
|
|
31006
31006
|
function buildManifest(opts, moduleId) {
|
|
31007
31007
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
31008
31008
|
const manifest = {
|
|
@@ -31214,7 +31214,7 @@ function buildZedSettings() {
|
|
|
31214
31214
|
var fs = __toESM(require("fs"));
|
|
31215
31215
|
var path = __toESM(require("path"));
|
|
31216
31216
|
|
|
31217
|
-
// node_modules/.pnpm/@dforge-core+metadata@0.0.
|
|
31217
|
+
// node_modules/.pnpm/@dforge-core+metadata@0.0.15/node_modules/@dforge-core/metadata/dist/index.js
|
|
31218
31218
|
var AggType = {
|
|
31219
31219
|
Sum: "sum",
|
|
31220
31220
|
Count: "count",
|
|
@@ -35146,9 +35146,20 @@ var reportAddSchema = {
|
|
|
35146
35146
|
description: external_exports.string(),
|
|
35147
35147
|
layout: external_exports.record(external_exports.string(), external_exports.unknown()),
|
|
35148
35148
|
datasets: external_exports.record(external_exports.string(), external_exports.unknown()),
|
|
35149
|
-
parameters: external_exports.record(external_exports.string(), external_exports.unknown()).optional()
|
|
35149
|
+
parameters: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
|
|
35150
|
+
"Report-level parameter declarations \u2014 the canonical home for a param, and the only one that fits a param several datasets use. Merged with each dataset's `params` into the report's one param set; this block wins on a code collision."
|
|
35151
|
+
),
|
|
35152
|
+
entities: external_exports.array(
|
|
35153
|
+
external_exports.object({
|
|
35154
|
+
entityCd: external_exports.string(),
|
|
35155
|
+
params: external_exports.record(external_exports.string(), external_exports.string()).optional(),
|
|
35156
|
+
orderNum: external_exports.number().int().optional()
|
|
35157
|
+
}).passthrough()
|
|
35158
|
+
).optional().describe(
|
|
35159
|
+
"RECORD-REPORT attachments \u2014 entities this report can be opened FROM, so it appears on the record's toolbar with the record's values feeding its params. Each entry: { entityCd, params: { <reportParamCd>: <sourceColumnOnThatEntity> }, orderNum? }. NOTE `params` here is the MAPPING (param \u2192 column), not a param declaration. Qualify a cross-module entityCd ('parties.party') and declare that module as a dependency. Source columns: the PK, a reference ('R') column, or a bounded scalar \u2014 never text/json/file. Requires 'metadata': '>=1.5.0' in the manifest."
|
|
35160
|
+
)
|
|
35150
35161
|
}).passthrough().describe(
|
|
35151
|
-
"Full report spec per reports.schema.json: { description, layout: { panels: [...] }, datasets: { code: {...} }, parameters? }."
|
|
35162
|
+
"Full report spec per reports.schema.json: { description, layout: { panels: [...] }, datasets: { code: {...} }, parameters?, entities?, reloadInterval? }. PARAMS ARE REPORT-SCOPED: declare them in the top-level `parameters` block, or as shorthand under `datasets.<cd>.params` when exactly one dataset uses the param \u2014 the installer merges both into the report's single param set, report level winning on a collision. A param several datasets use belongs at report level; there is no meaningful dataset to attribute it to. Within a param, use `required` (not `isRequired`) and nest a lookup's binding as `params: { link: { entity } }` (not a top-level `link`)."
|
|
35152
35163
|
)
|
|
35153
35164
|
};
|
|
35154
35165
|
function reportAdd(args) {
|
|
@@ -35919,6 +35930,119 @@ function moduleValidate(args) {
|
|
|
35919
35930
|
`folder code '${code2}' is used ${dupPaths.length} times (${dupPaths.join(", ")}). Codes must be unique across the whole tree: role rights say 'folder:${code2}' with no path, and translations key on 'folders.${code2}.label', so duplicates are ambiguous and silently collide.`
|
|
35920
35931
|
);
|
|
35921
35932
|
}
|
|
35933
|
+
for (const [rcode, rawReport] of Object.entries(reports)) {
|
|
35934
|
+
const report = rawReport ?? {};
|
|
35935
|
+
const where = `ui/reports.json \u2192 ${rcode}`;
|
|
35936
|
+
const declaredParams = /* @__PURE__ */ new Set();
|
|
35937
|
+
const checkParamDef = (loc, pcode, pdef) => {
|
|
35938
|
+
const p = pdef ?? {};
|
|
35939
|
+
if ("isRequired" in p) {
|
|
35940
|
+
err(
|
|
35941
|
+
`${where} \u2192 ${loc}.${pcode}`,
|
|
35942
|
+
"uses 'isRequired', which the installer does not read \u2014 the param installs as OPTIONAL. Rename it to 'required'."
|
|
35943
|
+
);
|
|
35944
|
+
}
|
|
35945
|
+
if ("link" in p) {
|
|
35946
|
+
err(
|
|
35947
|
+
`${where} \u2192 ${loc}.${pcode}`,
|
|
35948
|
+
'puts \'link\' at the top level, where the installer does not read it \u2014 a lookup param with no `params.link` has no autocomplete. Nest it: "params": { "link": { "entity": "\u2026" } }.'
|
|
35949
|
+
);
|
|
35950
|
+
}
|
|
35951
|
+
if ("fieldTypeCd" in p && "domain" in p) {
|
|
35952
|
+
err(
|
|
35953
|
+
`${where} \u2192 ${loc}.${pcode}`,
|
|
35954
|
+
"declares both 'fieldTypeCd' and 'domain' \u2014 install rejects the pair rather than picking a winner. A domain supplies the control; drop the fieldTypeCd."
|
|
35955
|
+
);
|
|
35956
|
+
}
|
|
35957
|
+
};
|
|
35958
|
+
const reportParams = report.parameters ?? {};
|
|
35959
|
+
for (const [pcode, pdef] of Object.entries(reportParams)) {
|
|
35960
|
+
declaredParams.add(pcode);
|
|
35961
|
+
checkParamDef("parameters", pcode, pdef);
|
|
35962
|
+
}
|
|
35963
|
+
const datasets = report.datasets ?? {};
|
|
35964
|
+
for (const [dcode, ds] of Object.entries(datasets)) {
|
|
35965
|
+
const dsParams = ds?.params ?? {};
|
|
35966
|
+
for (const [pcode, pdef] of Object.entries(dsParams)) {
|
|
35967
|
+
declaredParams.add(pcode);
|
|
35968
|
+
checkParamDef(`datasets.${dcode}.params`, pcode, pdef);
|
|
35969
|
+
}
|
|
35970
|
+
}
|
|
35971
|
+
const attachments = report.entities;
|
|
35972
|
+
if (attachments === void 0) continue;
|
|
35973
|
+
if (!Array.isArray(attachments)) {
|
|
35974
|
+
err(where, "'entities' must be an array of record-report attachments.");
|
|
35975
|
+
continue;
|
|
35976
|
+
}
|
|
35977
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35978
|
+
for (const raw of attachments) {
|
|
35979
|
+
const att = raw ?? {};
|
|
35980
|
+
const entityCd = att.entityCd;
|
|
35981
|
+
if (typeof entityCd !== "string" || entityCd.trim() === "") {
|
|
35982
|
+
err(where, "an entry in 'entities' is missing 'entityCd'.");
|
|
35983
|
+
continue;
|
|
35984
|
+
}
|
|
35985
|
+
const bare = entityCd.startsWith(`${manifest.code}.`) ? entityCd.slice(manifest.code.length + 1) : entityCd;
|
|
35986
|
+
if (seen.has(bare)) {
|
|
35987
|
+
err(
|
|
35988
|
+
where,
|
|
35989
|
+
`attaches to '${entityCd}' twice. One attachment per (entity, report) pair \u2014 merge the two 'params' maps into a single entry.`
|
|
35990
|
+
);
|
|
35991
|
+
continue;
|
|
35992
|
+
}
|
|
35993
|
+
seen.add(bare);
|
|
35994
|
+
if (!isKnownEntity(entityCd)) {
|
|
35995
|
+
err(
|
|
35996
|
+
where,
|
|
35997
|
+
`attaches to '${entityCd}', which is not a known entity (same-module, system, or a declared cross-module dependency). Qualify a foreign entity as 'module.entity' and add that module to the manifest's dependencies.`
|
|
35998
|
+
);
|
|
35999
|
+
}
|
|
36000
|
+
const paramMap = att.params ?? {};
|
|
36001
|
+
for (const [paramCd, source] of Object.entries(paramMap)) {
|
|
36002
|
+
if (!declaredParams.has(paramCd)) {
|
|
36003
|
+
err(
|
|
36004
|
+
where,
|
|
36005
|
+
`maps '${paramCd}' from '${entityCd}.${String(source)}', but '${paramCd}' is not a declared parameter of this report. Declare it in the report's 'parameters' block, or under the 'params' of the dataset that consumes it.`
|
|
36006
|
+
);
|
|
36007
|
+
continue;
|
|
36008
|
+
}
|
|
36009
|
+
if (typeof source !== "string" || source.trim() === "") {
|
|
36010
|
+
err(where, `maps parameter '${paramCd}' from an empty source column on '${entityCd}'.`);
|
|
36011
|
+
continue;
|
|
36012
|
+
}
|
|
36013
|
+
const fields = fieldDefsOf[bare];
|
|
36014
|
+
if (!fields) continue;
|
|
36015
|
+
const col = fields[source];
|
|
36016
|
+
if (!col) {
|
|
36017
|
+
err(
|
|
36018
|
+
where,
|
|
36019
|
+
`maps parameter '${paramCd}' from '${entityCd}.${source}', which is not a column of that entity.`
|
|
36020
|
+
);
|
|
36021
|
+
continue;
|
|
36022
|
+
}
|
|
36023
|
+
if (col.columnType === "S" || col.columnType === "F") {
|
|
36024
|
+
err(
|
|
36025
|
+
where,
|
|
36026
|
+
`maps parameter '${paramCd}' from '${entityCd}.${source}', a ${col.columnType === "S" ? "set ('S')" : "formula ('F')"} column. Map the PK, a reference ('R') column, or a bounded scalar.`
|
|
36027
|
+
);
|
|
36028
|
+
continue;
|
|
36029
|
+
}
|
|
36030
|
+
const db = typeof col.dbDatatype === "string" ? col.dbDatatype.toLowerCase() : "";
|
|
36031
|
+
if (col.columnType !== "R" && col.isPk !== true && /^(text|json|jsonb|bytea|uuid)$/.test(db)) {
|
|
36032
|
+
err(
|
|
36033
|
+
where,
|
|
36034
|
+
`maps parameter '${paramCd}' from '${entityCd}.${source}' (dbDatatype '${db}'), which is not a valid record-report parameter source. Allowed: the entity PK, a reference column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code).`
|
|
36035
|
+
);
|
|
36036
|
+
}
|
|
36037
|
+
}
|
|
36038
|
+
}
|
|
36039
|
+
if (seen.size > 0 && !deps.has("metadata")) {
|
|
36040
|
+
warn(
|
|
36041
|
+
where,
|
|
36042
|
+
`declares record-report attachments but the manifest has no 'metadata' dependency. The 'entity_report' table ships with the metadata system module \u2014 add "metadata": ">=1.5.0" to dependencies so install fails loudly on a platform without it, rather than the attachment quietly doing nothing.`
|
|
36043
|
+
);
|
|
36044
|
+
}
|
|
36045
|
+
}
|
|
35922
36046
|
const roleCodes = Object.keys(roles);
|
|
35923
36047
|
const localeFiles = fs11.existsSync(paths.translationsDir) ? fs11.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json")).sort() : [];
|
|
35924
36048
|
for (const raw of supportedLocales) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/dforge-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/iash44/dForge-core",
|
|
7
|
-
"packageManager": "pnpm@11.
|
|
7
|
+
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/dforge-core/dforge-mcp.git"
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"install-skills": "node scripts/install-skills.mjs"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@dforge-core/dforge-cli": "^0.2.
|
|
39
|
-
"@dforge-core/metadata": "^0.0.
|
|
38
|
+
"@dforge-core/dforge-cli": "^0.2.14",
|
|
39
|
+
"@dforge-core/metadata": "^0.0.15",
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -401,7 +401,7 @@ the server translates to an exact `::bigint` SQL literal.
|
|
|
401
401
|
#### `info(message, opts?)`, `warn(message, opts?)`
|
|
402
402
|
Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
|
|
403
403
|
|
|
404
|
-
A message is a **receipt for work the action did**, not a way to publish a computed value. An action whose only output is `info('<some computed number>')` stores nothing — it can't be audited, re-read or reported on, and the value arrives unformatted. Build a report, a formula column, or an action that writes a result record instead. See `dforge://reference/action-dsl`, *When an action is the wrong tool
|
|
404
|
+
A message is a **receipt for work the action did**, not a way to publish a computed value. An action whose only output is `info('<some computed number>')` stores nothing — it can't be audited, re-read or reported on, and the value arrives unformatted. Build a **record report** (a report with an `entities` attachment: it lands on the same record toolbar, but shows the numbers *and* the rows behind them, re-read live on every open), a formula column, or an action that writes a result record instead. Do not compute in DSL what a report dataset already aggregates. See `dforge://reference/action-dsl`, *When an action is the wrong tool*, and `dforge://reference/reports`.
|
|
405
405
|
|
|
406
406
|
```dsl
|
|
407
407
|
info('Created PO ' + po.po_number, {
|
|
@@ -247,11 +247,51 @@
|
|
|
247
247
|
"additionalProperties": false
|
|
248
248
|
},
|
|
249
249
|
|
|
250
|
+
"calculatedMember": {
|
|
251
|
+
"type": "object",
|
|
252
|
+
"description": "A synthetic, read-only member of a pivot-like view — a row (default) or column derived by a formula over other members at each cross-axis position. Platform-generic: any pivot-like surface consumes the same shape, so a module declares the metric in config with no platform code change. Pure presentation — no storage, evaluated client-side by the stock formula engine.",
|
|
253
|
+
"required": ["key", "label", "refs", "formula"],
|
|
254
|
+
"properties": {
|
|
255
|
+
"key": { "type": "string", "description": "Stable render key. Must not collide with a real member key." },
|
|
256
|
+
"axis": { "type": "string", "enum": ["row", "column"], "default": "row", "description": "Which axis the member is added to. A calculated ROW is a formula over other rows evaluated once per column; a calculated COLUMN is the transpose." },
|
|
257
|
+
"label": { "type": "string", "description": "Member label shown in the row/column head." },
|
|
258
|
+
"field": { "type": "string", "description": "Value field the refs read. Defaults to the surface's first value field." },
|
|
259
|
+
"refs": {
|
|
260
|
+
"type": "object",
|
|
261
|
+
"description": "Formula bracket-alias → referenced member identity on the same axis.",
|
|
262
|
+
"additionalProperties": { "type": "string" }
|
|
263
|
+
},
|
|
264
|
+
"formula": { "type": "string", "description": "Formula over the aliases, e.g. `[gp] / [rev] * 100`. Same `[name]` syntax as column formulas. A non-finite result (÷0, missing denominator) renders empty." },
|
|
265
|
+
"position": {
|
|
266
|
+
"description": "Placement on the axis. Defaults to the end.",
|
|
267
|
+
"oneOf": [
|
|
268
|
+
{ "type": "object", "required": ["after"], "properties": { "after": { "type": "string", "description": "Key of the member to place this one after." } }, "additionalProperties": false },
|
|
269
|
+
{ "type": "object", "required": ["at"], "properties": { "at": { "type": "string", "enum": ["end"] } }, "additionalProperties": false }
|
|
270
|
+
]
|
|
271
|
+
},
|
|
272
|
+
"depth": { "type": "integer", "minimum": 0, "description": "Indent depth (row members only; 0 = top level)." },
|
|
273
|
+
"format": {
|
|
274
|
+
"type": "object",
|
|
275
|
+
"description": "Display format for the result.",
|
|
276
|
+
"properties": {
|
|
277
|
+
"style": { "type": "string", "enum": ["number", "percent"], "default": "number" },
|
|
278
|
+
"decimals": { "type": "integer", "minimum": 0, "default": 1 }
|
|
279
|
+
},
|
|
280
|
+
"additionalProperties": false
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"additionalProperties": false
|
|
284
|
+
},
|
|
250
285
|
"matrixViewConfig": {
|
|
251
286
|
"type": "object",
|
|
252
287
|
"description": "Matrix (pivot) view: rowAxis × colAxis grid with a value-source cell entity holding one record per (rowKey, colKey).",
|
|
253
288
|
"required": ["rowAxis", "colAxis", "cell"],
|
|
254
289
|
"properties": {
|
|
290
|
+
"calculatedMembers": {
|
|
291
|
+
"type": "array",
|
|
292
|
+
"description": "Synthetic read-only members derived by formula over OTHER members' values (e.g. a Gross Margin % row). Spliced into the rendered axis; never loaded or saved.",
|
|
293
|
+
"items": { "$ref": "#/$defs/calculatedMember" }
|
|
294
|
+
},
|
|
255
295
|
"rowAxis": {
|
|
256
296
|
"description": "Row axis values: an entity's records ('dataset') or a column's dropdown options ('dropdown').",
|
|
257
297
|
"oneOf": [
|
|
@@ -133,6 +133,12 @@
|
|
|
133
133
|
"items": { "type": "string", "pattern": "^[a-z]{2,3}(-[A-Z]{2})?$" },
|
|
134
134
|
"description": "Non-English locales the package promises to translate. Each entry MUST match a translations/<locale>.json file, and every translatable resource (entity/field labels, folders, views, menus + items, actions + params, reports + datasets + params, settings) MUST have a label in that file. English is the default — don't list 'en' or 'en-US'."
|
|
135
135
|
},
|
|
136
|
+
"features": {
|
|
137
|
+
"type": "array",
|
|
138
|
+
"items": { "type": "string", "enum": ["report-security"] },
|
|
139
|
+
"uniqueItems": true,
|
|
140
|
+
"description": "Opt-in platform behaviours this package was authored for. Absent or empty = the pre-feature behaviour for all of them, so a module written before a feature existed keeps working until its author declares it and republishes. Unknown names are REJECTED at package load, never ignored — a misspelled feature would silently leave the old behaviour in place while the manifest claims otherwise. Values: 'report-security' = this module's reports require 'E' on their sec_object; declare the matching \"report:<code>\": \"E\" grants in security/roles.json first, or the reports go dark for every role."
|
|
141
|
+
},
|
|
136
142
|
"created": {
|
|
137
143
|
"type": "string",
|
|
138
144
|
"format": "date",
|
|
@@ -24,10 +24,42 @@
|
|
|
24
24
|
"description": "Map of dataset code → dataset definition. Panels reference datasets by code via `datasetCd`.",
|
|
25
25
|
"additionalProperties": { "$ref": "#/$defs/dataset" }
|
|
26
26
|
},
|
|
27
|
+
"reloadInterval": {
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"minimum": 0,
|
|
30
|
+
"description": "Auto-refresh interval in seconds. Omit (or 0) for no auto-refresh."
|
|
31
|
+
},
|
|
27
32
|
"parameters": {
|
|
28
33
|
"type": "object",
|
|
29
|
-
"description": "Report-level
|
|
34
|
+
"description": "Report-level parameter declarations — the canonical home for a parameter, and the only one that fits a parameter several datasets use. Parameters are report-scoped throughout the platform: one `param_set` per report, `report.params.save` in the authoring API, and `report.get` flattens per-dataset defaults report-wide before use. A parameter declared under a dataset's `params` is shorthand for declaring it here; the installer merges both into one set, with this block winning on a code collision.",
|
|
30
35
|
"additionalProperties": { "$ref": "#/$defs/paramDef" }
|
|
36
|
+
},
|
|
37
|
+
"entities": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"description": "Record-report attachments: entities this report can be opened from, with the mapping from report parameter to source column on that entity. Each entry becomes one `dForge.entity_report` row. See docs/business-logic/record-reports.md.",
|
|
40
|
+
"items": { "$ref": "#/$defs/entityAttachment" }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"additionalProperties": false
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
"entityAttachment": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"description": "One (entity, report) attachment. The binding is per-attachment, not per-report — the same report can attach to several entities with different parameter mappings.",
|
|
49
|
+
"required": ["entityCd"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"entityCd": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Entity the report attaches to, qualified `module.entity` for anything outside the declaring module. The owning module must be this module or a declared dependency."
|
|
54
|
+
},
|
|
55
|
+
"params": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"description": "Parameter mapping: report param code → source column on the entity. Stored as `entity_report.param_map` and resolved server-side from the record's PK. Source columns are limited to the PK, reference (`R`) columns, and bounded scalars (number, date/datetime, bool, dropdown/radio/flags code); source and target must be type-compatible. Params left unmapped keep their normal behaviour (default value, or prompt the user).",
|
|
58
|
+
"additionalProperties": { "type": "string" }
|
|
59
|
+
},
|
|
60
|
+
"orderNum": {
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"description": "Sort position in the record's combined action + report menu."
|
|
31
63
|
}
|
|
32
64
|
},
|
|
33
65
|
"additionalProperties": false
|
|
@@ -228,7 +260,7 @@
|
|
|
228
260
|
},
|
|
229
261
|
"params": {
|
|
230
262
|
"type": "object",
|
|
231
|
-
"description": "
|
|
263
|
+
"description": "Parameters declared on this dataset — shorthand for the report-level `parameters` block, convenient when exactly one dataset uses the parameter. Either way the parameter is REPORT-scoped: `ReportRegistrar` merges the report-level block and every dataset's `params` into the report's one `param_set`, so a parameter declared here serves the whole report (the param bar, saved param sets, `@param` filters in any dataset, and record-report `entities[].params` mappings). On a code collision the report-level block wins. Declare a parameter used by several datasets at report level — there is no meaningful dataset to attribute it to.",
|
|
232
264
|
"additionalProperties": { "$ref": "#/$defs/paramDef" }
|
|
233
265
|
}
|
|
234
266
|
},
|
|
@@ -330,30 +362,22 @@
|
|
|
330
362
|
"type": "integer",
|
|
331
363
|
"description": "Display order of the parameter in the report's parameter form. Read by ReportRegistrar (paramDef.OrderNum); falls back to declaration order when omitted."
|
|
332
364
|
},
|
|
365
|
+
"columnType": {
|
|
366
|
+
"type": "string",
|
|
367
|
+
"enum": ["R"],
|
|
368
|
+
"description": "Declares a REFERENCE parameter: its submitted value is the whole picked record rather than the bare key. The JSON counterpart of the action DSL's `paramCd: ref <entityCd>`, stored in the same `param.column_type`. Optional — omitting it (a plain `lookup`) submits the key, which is what a filter comparing against a reference column needs, since the server destructures a key object across every key column and ignores non-key fields. Declare \"R\" when the report wants the record itself, not just its identity."
|
|
369
|
+
},
|
|
333
370
|
"required": {
|
|
334
371
|
"type": "boolean",
|
|
335
372
|
"default": false,
|
|
336
|
-
"description": "Whether the user must provide a value
|
|
337
|
-
},
|
|
338
|
-
"isRequired": {
|
|
339
|
-
"type": "boolean",
|
|
340
|
-
"description": "Alternate spelling of `required` used by some modules. Prefer `required` for new code."
|
|
373
|
+
"description": "Whether the user must provide a value. Stored inverted as `param.is_nullable`. There is no `isRequired` spelling — `ReportParamDef` reads this key only."
|
|
341
374
|
},
|
|
342
375
|
"default": {
|
|
343
376
|
"description": "Default value applied if the user doesn't provide one"
|
|
344
377
|
},
|
|
345
|
-
"link": {
|
|
346
|
-
"type": "object",
|
|
347
|
-
"description": "Entity link for lookup-type params (alternative to nesting under `params.link`). Shape: { entity: '<code>', otherKey?: '<column>' }.",
|
|
348
|
-
"properties": {
|
|
349
|
-
"entity": { "type": "string" },
|
|
350
|
-
"otherKey": { "type": "string" }
|
|
351
|
-
},
|
|
352
|
-
"additionalProperties": false
|
|
353
|
-
},
|
|
354
378
|
"params": {
|
|
355
379
|
"type": "object",
|
|
356
|
-
"description": "Field-type-specific params
|
|
380
|
+
"description": "Field-type-specific params, nested under this key — there is no top-level `link`. A `lookup` param needs `{ \"link\": { \"entity\": \"<code>\", \"otherKey\": \"<column>\" } }`; `otherKey` auto-fills from the target's PK when omitted. Without `params.link` a lookup param installs, warns, and has no autocomplete."
|
|
357
381
|
}
|
|
358
382
|
},
|
|
359
383
|
"additionalProperties": false
|
|
@@ -11,11 +11,14 @@ An action exists to **change something**. Its message is a receipt for work that
|
|
|
11
11
|
|
|
12
12
|
Do **not** write an action whose `execute:` block only reads data, computes a value and reports it with `info()`. The result is a line of text that is never stored: it can't be audited, referenced, re-read or reported on, and every reader has to re-run the action to see it again. The value also reaches the user unformatted — a raw `query()` result carries no column metadata, so money renders as `1000.0099999999999` instead of `1,000.01`.
|
|
13
13
|
|
|
14
|
+
**Do not compute in the DSL what a report can compute.** Sums, ratios, ageing buckets, exposure, "how much is outstanding" — a report dataset already does aggregation, formatting, drill-through and row caps, and it re-reads live data every time it opens. DSL arithmetic over `select()` results reproduces that badly, once, in a place nothing else can reuse. Reach for the DSL when the number has to be **written down** or has to **stop** something; reach for a report when it has to be **looked at**.
|
|
15
|
+
|
|
14
16
|
If you are about to write a `check_…`, `calculate_…` or `show_…` action, build one of these instead:
|
|
15
17
|
|
|
16
18
|
| What's actually needed | Build this |
|
|
17
19
|
|---|---|
|
|
18
|
-
| The user needs to **see** current numbers | A **report**
|
|
20
|
+
| The user needs to **see** current numbers **for the record they're on** | A **record report** — a report with an `entities` attachment, which puts it on that record's toolbar and feeds its params from the record. This is the direct replacement for a `check_…` action: same entry point, but the user sees the open invoices and the ageing, not just "FAILED". See `references/reports.md`. |
|
|
21
|
+
| The user needs to **see** current numbers generally | A **report** or a data view — both are metadata-driven, so values format correctly, and the user can return to them any time without re-running anything. |
|
|
19
22
|
| A derived value **belongs to the record** | A **formula column** (`columnType: "F"`) or a roll-up — computed on read, visible on the card and in grids, filterable and sortable. |
|
|
20
23
|
| The **verdict itself matters** (approvals, credit checks, compliance) | An action that **writes a result record** — e.g. `credit_check(customer, checked_at, limit, outstanding, projected, verdict, checked_by)`. Now it's auditable and reportable, and `info()` becomes a legitimate receipt with a link to the new row. |
|
|
21
24
|
| The check must **block** something | A **trigger** with `async: false`, or a `canExecute:` guard on the action being gated — enforcement, not narration. |
|
|
@@ -71,6 +71,35 @@ The manifest lists every file in the package by logical key. The installer reads
|
|
|
71
71
|
| `supportedLocales` | string[] | IETF locale tags that the module ships translations for, e.g. `["de-DE", "uk-UA"]`. Files are **auto-discovered** at `./translations/{locale}.json` — there is no per-file manifest entry. English (`en`/`en-*`) is the default and must not be listed. |
|
|
72
72
|
| `printTemplates` | object | `{ "template_code": "./print_templates/template.scriban" }` |
|
|
73
73
|
| `webhooks` | string | path to `./webhooks.json` |
|
|
74
|
+
| `features` | string[] | Opt-in platform behaviours this package was authored for, e.g. `["report-security"]`. Absent/empty = the pre-feature behaviour. See **Feature opt-ins** below. |
|
|
75
|
+
|
|
76
|
+
## Feature opt-ins
|
|
77
|
+
|
|
78
|
+
`dependencies` answers "does the platform have this?". `features` answers "should this behaviour apply to **my** module?".
|
|
79
|
+
|
|
80
|
+
They exist for opposite failure modes. A dependency is a hard gate — the module will not install without it. A feature is the reverse: the platform already has the behaviour, but switching it on for every installed module at once would break every package written before it existed. So it stays off per module until the author asks for it.
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
"features": ["report-security"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Feature | Effect |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `report-security` | This module's reports require the `E` right on their security object — checked when running or opening a report and when reading or changing its saved presets and panel layouts. Without it, a report is reachable by anyone who can read the underlying data. |
|
|
89
|
+
|
|
90
|
+
**Adopting one is two releases, in order:**
|
|
91
|
+
|
|
92
|
+
1. Add `"report:<report_code>": "E"` to every role in `security/roles.json` that should reach each report. Bump `version`, install.
|
|
93
|
+
2. Add `"features": ["report-security"]`. Bump `version` again, install.
|
|
94
|
+
|
|
95
|
+
Do it in the other order and every report in the module denies for every role until the grants land. Both steps need a version bump — a same-version install is skipped, so the tenant would keep the old manifest.
|
|
96
|
+
|
|
97
|
+
**Rules:**
|
|
98
|
+
|
|
99
|
+
- Unknown names are **rejected** at package load, not ignored. A misspelled `report-securty` would otherwise leave the old behaviour in place while the manifest claims the protection is on.
|
|
100
|
+
- Casing and surrounding whitespace are normalised; the canonical spelling is lowercase.
|
|
101
|
+
- Removing a feature genuinely removes it on the next install — an opt-in is a migration window, not a one-way door.
|
|
102
|
+
- Installing against an older platform that predates the feature is safe: unknown manifest keys are ignored there, so the module runs the old behaviour.
|
|
74
103
|
|
|
75
104
|
## Dependencies
|
|
76
105
|
|
|
@@ -7,7 +7,7 @@ SP files: `logic/reports/rpt_*.sql`
|
|
|
7
7
|
|
|
8
8
|
## Structure
|
|
9
9
|
|
|
10
|
-
A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...] }`), and optional `
|
|
10
|
+
A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...] }`), optional per-dataset `params`, and an optional `entities` array (record-report attachments). Panels reference datasets by code via `datasetCd`.
|
|
11
11
|
|
|
12
12
|
```json
|
|
13
13
|
{
|
|
@@ -17,12 +17,18 @@ A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...
|
|
|
17
17
|
"pipeline": {
|
|
18
18
|
"caption": "Pipeline Data",
|
|
19
19
|
"datasetType": "Q",
|
|
20
|
+
"params": {
|
|
21
|
+
"min_amount": { "fieldTypeCd": "number", "label": "Min Amount", "required": false, "default": 0 }
|
|
22
|
+
},
|
|
20
23
|
"query": {
|
|
21
24
|
"entityCd": "opportunity",
|
|
22
25
|
"columns": ["stage", "amount", "lead_source", "customer.name"],
|
|
23
26
|
"filter": {
|
|
24
27
|
"g": "and",
|
|
25
|
-
"i": [
|
|
28
|
+
"i": [
|
|
29
|
+
{ "c": "stage", "o": "nIn", "v": ["Closed Won", "Closed Lost"] },
|
|
30
|
+
{ "c": "amount", "o": "grEq", "v": "@min_amount" }
|
|
31
|
+
]
|
|
26
32
|
},
|
|
27
33
|
"sort": [ { "c": "amount", "d": "desc" } ]
|
|
28
34
|
}
|
|
@@ -38,9 +44,6 @@ A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...
|
|
|
38
44
|
},
|
|
39
45
|
{ "vizType": "table", "datasetCd": "pipeline", "title": "Pipeline Detail" }
|
|
40
46
|
]
|
|
41
|
-
},
|
|
42
|
-
"parameters": {
|
|
43
|
-
"min_amount": { "fieldTypeCd": "number", "label": "Min Amount", "required": false, "default": 0 }
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
}
|
|
@@ -85,7 +88,7 @@ For SQL the query builder can't express — window functions, CTEs, cross-schema
|
|
|
85
88
|
"spCd": "rpt_ar_aging",
|
|
86
89
|
"params": {
|
|
87
90
|
"as_of_date": { "fieldTypeCd": "date", "label": "As of Date", "required": true, "default": "=NOW()" },
|
|
88
|
-
"customer_id": { "fieldTypeCd": "lookup", "label": "Customer", "required": false, "params": { "
|
|
91
|
+
"customer_id": { "fieldTypeCd": "lookup", "label": "Customer", "required": false, "params": { "link": { "entity": "account" } } }
|
|
89
92
|
},
|
|
90
93
|
"columnsDef": {
|
|
91
94
|
"customer_name": { "label": "Customer", "fieldTypeCd": "text", "baseDatatypeCd": "string", "width": 200 },
|
|
@@ -99,7 +102,7 @@ Key differences from entity datasets:
|
|
|
99
102
|
- `datasetType: "S"`.
|
|
100
103
|
- `spCd` — the stored-procedure code (the function name **without** the schema prefix; resolved to `sp_id` at install). (**Not** `sp` or `procedureName`.)
|
|
101
104
|
- `columnsDef` — **required** — the platform can't infer columns from a function.
|
|
102
|
-
- Params
|
|
105
|
+
- Params work exactly as they do for an entity dataset, and `datasetType` makes no difference: declare them at report level (`parameters`) or on the dataset (`params`). Report level wins on a code collision.
|
|
103
106
|
|
|
104
107
|
**Multi-result-set SPs** map extra datasets to the same function via `parentDatasetCd` (the dataset that owns the SP call) + `parentRef` (the named refcursor):
|
|
105
108
|
|
|
@@ -139,16 +142,33 @@ $$;
|
|
|
139
142
|
|
|
140
143
|
## Parameters
|
|
141
144
|
|
|
142
|
-
Parameters prompt the user before running the report.
|
|
145
|
+
Parameters prompt the user before running the report.
|
|
146
|
+
|
|
147
|
+
**Parameters are report-scoped**, so there are two legal declaration sites and they mean the same thing:
|
|
148
|
+
|
|
149
|
+
- **`parameters`** at report level — the canonical home, and the only one that fits a param several datasets use.
|
|
150
|
+
- **`datasets.<cd>.params`** — shorthand, fine when exactly one dataset uses it. Every module written before this existed uses it, and it stays fully supported.
|
|
151
|
+
|
|
152
|
+
The installer merges both into the report's single param set (**report level wins** on a code collision), so either way the param serves the whole report: the param bar, saved param sets, `@param` filters in *any* dataset, and record-report mappings.
|
|
153
|
+
|
|
154
|
+
> Why report-scoped: the declaration is stored once per report (`report.param_set_id` → `param_set`), the authoring API is `report.params.save`, and `report.get` flattens per-dataset defaults report-wide before use. There is no dataset-scoped parameter behaviour to choose between.
|
|
143
155
|
|
|
144
156
|
```json
|
|
145
157
|
"parameters": {
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
"customer": { "fieldTypeCd": "lookup", "label": "Customer", "params": { "link": { "entity": "account" } } }
|
|
159
|
+
},
|
|
160
|
+
"datasets": {
|
|
161
|
+
"sales": {
|
|
162
|
+
"datasetType": "Q",
|
|
163
|
+
"params": {
|
|
164
|
+
"start_date": { "fieldTypeCd": "date", "label": "Start Date", "required": true, "default": "=STARTMONTH()" },
|
|
165
|
+
"status": { "domain": "fin.doc_status", "label": "Status" },
|
|
166
|
+
"region": { "fieldTypeCd": "dropdown", "label": "Region", "params": { "options": [
|
|
167
|
+
{ "value": "north", "label": "North" }, { "value": "south", "label": "South" }
|
|
168
|
+
] } }
|
|
169
|
+
},
|
|
170
|
+
"query": { "entityCd": "invoice", "columns": ["invoice_number", "total"] }
|
|
171
|
+
}
|
|
152
172
|
}
|
|
153
173
|
```
|
|
154
174
|
|
|
@@ -159,9 +179,11 @@ Parameters prompt the user before running the report. Declare them per-report (`
|
|
|
159
179
|
| `label` | Display label in the parameter dialog |
|
|
160
180
|
| `required` | Whether the user must fill this before running |
|
|
161
181
|
| `default` | Plain value or `=`-prefixed formula (`"=NOW()"`, `"=STARTMONTH()"`, `"=TODAY()"`) |
|
|
162
|
-
| `params` | Extra config — `options` for dropdowns, `
|
|
182
|
+
| `params` | Extra config — `options` for dropdowns, `link: { entity, otherKey? }` for lookups |
|
|
163
183
|
| `orderNum` | Display order in the parameter form (falls back to declaration order) |
|
|
164
184
|
|
|
185
|
+
Two spellings that look right and are silently ignored: **`isRequired`** (the key is `required`, so the param installs as optional) and a **top-level `link`** (it must be nested under `params`, so the lookup installs with no autocomplete). Both are validator errors.
|
|
186
|
+
|
|
165
187
|
**Dropdown params: prefer `domain`, and never ship bare codes.** A list shared with a column belongs on a `column_domain` — bind the param to it and the options plus their translations are authored once (`column-domains.md`). For a list that exists only for this report, write the rich option form (`{ "value": "north", "label": "North" }`); a bare `"options": ["north", "south"]` renders the raw codes in **every** locale, English included. Per-locale labels go under `reports.<cd>.params.<param_cd>.options` (`translations.md`).
|
|
166
188
|
|
|
167
189
|
Reference a parameter with `@param_code` inside a query filter value:
|
|
@@ -260,14 +282,72 @@ Dashboard KPI/chart *tiles* get the same support via a `sources` map in the tile
|
|
|
260
282
|
|
|
261
283
|
`config.chartSize` ∈ `sm | m | l | xl` controls the default chart height (`m` when omitted).
|
|
262
284
|
|
|
285
|
+
## Record reports (`entities`)
|
|
286
|
+
|
|
287
|
+
A report can be **attached to an entity** so it opens from a record — the way a print template does — with the record's values feeding its parameters. Add an `entities` array to the report:
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
"credit_check": {
|
|
291
|
+
"description": "Customer Credit Check — limit against outstanding AR and open quotes",
|
|
292
|
+
"entities": [
|
|
293
|
+
{ "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 },
|
|
294
|
+
{ "entityCd": "crm.quote", "params": { "customer_id": "customer_id" }, "orderNum": 45 }
|
|
295
|
+
],
|
|
296
|
+
"parameters": {
|
|
297
|
+
"customer_id": {
|
|
298
|
+
"label": "Customer",
|
|
299
|
+
"fieldTypeCd": "lookup",
|
|
300
|
+
"params": { "link": { "entity": "parties.party" } },
|
|
301
|
+
"required": true
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
"layout": { "panels": [ { "vizType": "table", "datasetCd": "open_invoices" } ] },
|
|
305
|
+
"datasets": {
|
|
306
|
+
"open_invoices": {
|
|
307
|
+
"caption": "Open Invoices",
|
|
308
|
+
"datasetType": "Q",
|
|
309
|
+
"query": {
|
|
310
|
+
"entityCd": "invoice",
|
|
311
|
+
"columns": ["invoice_number", "due_date", "amount_due"],
|
|
312
|
+
"filter": { "g": "and", "i": [ { "c": "customer_id", "o": "eq", "v": "@customer_id" } ] }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Each entry becomes one attachment row, and the report gains a toolbar entry on that entity's record. Note the two different `params` keys:
|
|
320
|
+
|
|
321
|
+
- `entities[].params` — the **mapping**: report param code → **source column on the attached entity**.
|
|
322
|
+
- `parameters` / `datasets.<cd>.params` — the param **declaration**. The mapping can only name codes declared in one of those.
|
|
323
|
+
|
|
324
|
+
| Rule | Detail |
|
|
325
|
+
|---|---|
|
|
326
|
+
| `entityCd` | Qualified `module.entity` for anything outside this module, and that module must be a **declared dependency**. |
|
|
327
|
+
| Source columns | The entity PK, a reference (`R`) column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code). `text`, `json`, `file`/`image` are rejected. |
|
|
328
|
+
| Types | Source and target must be compatible; only `lookup`↔`number` and `date`↔`datetime` widen. A names-only match like `{ "customer_id": "created_date" }` is an error, not a runtime surprise. |
|
|
329
|
+
| One per pair | One attachment per (entity, report). Two entries for the same entity overwrite each other. |
|
|
330
|
+
| Unmapped params | Keep their normal behaviour — default value, or prompt the user. |
|
|
331
|
+
| Mapped params | Are **hidden** on the record-report page. The URL is about this record; pivoting means going back to a record. |
|
|
332
|
+
| Dependency | Declare `"metadata": ">=1.5.0"` — the attachment table ships with the metadata system module, so this is the gate that fails loudly on an older platform instead of the attachment quietly doing nothing. This is metadata's **`version`**; dependency ranges are never checked against `dbSchemaVersion`, so do not use the `1.4.0` of the migration that creates the table. |
|
|
333
|
+
| Rights | Report `E` is still required. A bridge role must grant `E` on the report **and** `S` on the dataset entities. |
|
|
334
|
+
|
|
335
|
+
There is no `canExecute` on an attachment: if the record exists the report is meaningful for it, and an empty report is a legitimate answer.
|
|
336
|
+
|
|
337
|
+
> **Prefer a record report over an action that calculates.** See *When an action is the wrong tool* in `references/action-dsl.md`. If the `execute:` block only reads, computes and `info()`s a number, it is not an action — a printed number is never stored, can't be re-read, and shows the verdict without the working. Attach a report instead: it gets you KPI tiles, charts, the underlying rows, drill-through, saved layouts and row caps for free, and the DSL you don't write is DSL that can't drift from the data.
|
|
338
|
+
>
|
|
339
|
+
> Keep it an action when it **changes** something — writing the result to a column, creating a record, or *blocking* a transition. "Explain the number" is a report; "refuse the over-limit quote" is an action or trigger.
|
|
340
|
+
|
|
263
341
|
## Grants
|
|
264
342
|
|
|
265
|
-
Grant report access with the `E` right:
|
|
343
|
+
Grant report access with the `E` right — a **colon** prefix, never a dot:
|
|
266
344
|
|
|
267
345
|
```json
|
|
268
|
-
"rights": { "report
|
|
346
|
+
"rights": { "report:sales_pipeline": "E" }
|
|
269
347
|
```
|
|
270
348
|
|
|
349
|
+
A dependency's report is granted with the qualified form, `"report:fin.ar_aging": "E"` — bridge modules need this to grant on a report they attach but do not own.
|
|
350
|
+
|
|
271
351
|
## Common mistakes
|
|
272
352
|
|
|
273
353
|
- Using a bare `"layout": [ ... ]` array — `layout` is an **object**: `"layout": { "panels": [ ... ] }`.
|
|
@@ -276,5 +356,11 @@ Grant report access with the `E` right:
|
|
|
276
356
|
- Using `sp` / `procedureName` for an SP dataset — the field is `spCd`.
|
|
277
357
|
- Forgetting `columnsDef` on an SP dataset — **required**.
|
|
278
358
|
- Forgetting to grant `E` on the report in at least one role — it becomes invisible.
|
|
359
|
+
- Writing the rights key as `report.<code>` — actions/reports/folders take a **colon**: `report:<code>`.
|
|
360
|
+
- Writing `isRequired` instead of `required`, or a top-level `link` instead of `params.link` — both are ignored at install.
|
|
361
|
+
- Declaring both `fieldTypeCd` and `domain` on one param — install rejects the pair rather than picking a winner.
|
|
362
|
+
- Mapping a record-report attachment onto a param no dataset declares, or from a `text`/`json` column — both are rejected.
|
|
363
|
+
- Attaching a report to another module's entity without declaring that module as a dependency.
|
|
279
364
|
- Forgetting `p_folder_uid` / `p_user_id` as the first two SP function params — the call fails.
|
|
280
365
|
- Referencing a parameter as `$param` — use `@param_code` in filters.
|
|
366
|
+
- Writing an action that computes a number and `info()`s it, where a record report would show the working.
|
|
@@ -73,6 +73,9 @@ For each entity:
|
|
|
73
73
|
- [ ] Each source has `entityCode` and `columns`
|
|
74
74
|
- [ ] `entityCode` points to an entity that exists
|
|
75
75
|
- [ ] Column codes in `columns` all exist on the entity
|
|
76
|
+
- [ ] No `columns` entry is a **set** (`columnType: "S"`) — a 1:N collection has no per-row value to put in a column; show it on the record card instead. **Rejected at validate/pack**
|
|
77
|
+
- [ ] No `columns` entry is a **dotted navigation path** (`owner.email`, `account.company_name`) — a view's column list is matched against the entity's own columns, so a dotted entry resolves to nothing. Add a Reference (`R`) or Formula (`F`) column to the entity and list that. **Rejected at validate/pack**
|
|
78
|
+
- [ ] A listed column does **not** need `V` in its flags — the renderer promotes anything the list names (that is how trait-contributed `created_by_user`, flagged `I`, appears). Listing a non-`V` column is valid intent, not a mistake
|
|
76
79
|
- [ ] The rendered entity has **at least one visible scalar column** (a field with `V` in `flags`, `columnType` not `S`) — a grid/list/kanban/etc. view over an entity whose fields are all hidden (or only sets) is **rejected** at validate/pack and would render "No visible columns configured for this entity." `diagram`/`matrix`/`library` are exempt
|
|
77
80
|
- [ ] If set, `viewType` is from the supported list (`grid`, `list`, `kanban`, `calendar`, `gallery`, `tree-grid`, `diagram`, `master-detail`, `library`, `matrix`)
|
|
78
81
|
- [ ] A `matrix` view has a `viewConfig` with `rowAxis`, `colAxis`, and `cell` (cell `entity` matches the primary `dataSources` entity; `rowKey`/`colKey` are real cell columns)
|
|
@@ -89,6 +92,16 @@ For each entity:
|
|
|
89
92
|
- [ ] Every `reportCode` points to a report that exists
|
|
90
93
|
- [ ] `orderNum` is set on each node for deterministic ordering
|
|
91
94
|
|
|
95
|
+
## Reports (`ui/reports.json`)
|
|
96
|
+
|
|
97
|
+
- [ ] Params are declared in the report-level `parameters` block, or under `datasets.<cd>.params` — a param several datasets use belongs at report level (the installer merges both; report level wins on a collision)
|
|
98
|
+
- [ ] Params use `required` (not `isRequired`), and a lookup nests its binding as `params: { link: { entity } }` (not a top-level `link`)
|
|
99
|
+
- [ ] No param declares both `fieldTypeCd` and `domain`
|
|
100
|
+
- [ ] `layout` is an object (`{ "panels": [...] }`), and the chart kind is `config.chartType` with `vizType: "chart"`
|
|
101
|
+
- [ ] Record-report attachments (`entities`): every mapped param code is declared at report level or on a dataset; every source column is the PK, a reference (`R`) column, or a bounded scalar; cross-module `entityCd` is qualified and its module is a declared dependency
|
|
102
|
+
- [ ] A module with attachments declares `"metadata": ">=1.5.0"`
|
|
103
|
+
- [ ] No `check_…` / `calculate_…` action exists that a record report should be
|
|
104
|
+
|
|
92
105
|
## Security
|
|
93
106
|
|
|
94
107
|
- [ ] `security/roles.json` exists (at least one role)
|
|
@@ -172,9 +172,12 @@ to fill a sub-step** — a pure CRUD module skips Phase 2 entirely (record it wi
|
|
|
172
172
|
|
|
173
173
|
**2a Actions.** First check the action is an action at all: it must **change**
|
|
174
174
|
something. If the `execute:` block would only read, compute and `info()` the
|
|
175
|
-
answer, build a report
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
answer, build a **record report** (a report with an `entities` attachment — same
|
|
176
|
+
toolbar entry point, but it shows the working and re-reads live data), a formula
|
|
177
|
+
column, or an action that writes a result record instead — a printed number is
|
|
178
|
+
never stored and can't be re-read (`dforge://reference/action-dsl`, *When an
|
|
179
|
+
action is the wrong tool*). Do not compute in DSL what a report dataset already
|
|
180
|
+
aggregates. Then load
|
|
178
181
|
all four resources in the action row — the wrong-field-access /
|
|
179
182
|
wrong-batch-flag / wrong-`ui/actions.json`-property mistakes only surface when
|
|
180
183
|
you cross-check them. Draft the DSL, run
|
|
@@ -219,10 +222,31 @@ pointing at the views.
|
|
|
219
222
|
|
|
220
223
|
If none fire, skip specialized views for that entity.
|
|
221
224
|
|
|
222
|
-
**3c. Reports** only when aggregation/grouping isn't covered by views
|
|
225
|
+
**3c. Reports** only when aggregation/grouping isn't covered by views — or when
|
|
226
|
+
Phase 2a sent you here because the "action" only wanted to show a number.
|
|
227
|
+
|
|
228
|
+
Params are **report-scoped**: declare them in the report-level `parameters`
|
|
229
|
+
block, or as shorthand under `datasets.<cd>.params` when exactly one dataset uses
|
|
230
|
+
the param. The installer merges both into the report's one param set, report level
|
|
231
|
+
winning on a collision — so a param several datasets share belongs at report level.
|
|
232
|
+
Grant with a **colon** key: `"report:<code>": "E"`.
|
|
233
|
+
|
|
234
|
+
A report that answers a question *about one record* takes an `entities`
|
|
235
|
+
attachment, which puts it on that record's toolbar with the record's values
|
|
236
|
+
feeding its params:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
"entities": [ { "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 } ]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`entities[].params` is the **mapping** (report param → source column), not a
|
|
243
|
+
param declaration. Qualify a cross-module `entityCd` and declare that module as a
|
|
244
|
+
dependency; add `"metadata": ">=1.5.0"` too. Source columns are limited to the
|
|
245
|
+
PK, a reference column, or a bounded scalar. Full rules:
|
|
246
|
+
`dforge://reference/reports`.
|
|
223
247
|
|
|
224
248
|
**Exit:** every entity has a grid and a menu entry; every specialized view has a
|
|
225
|
-
stated trigger.
|
|
249
|
+
stated trigger; every report's params sit on a dataset.
|
|
226
250
|
|
|
227
251
|
## Phase 4 — Polish (mostly optional)
|
|
228
252
|
|