diff --git a/.changeset/26548-report-date-range-export.md b/.changeset/26548-report-date-range-export.md new file mode 100644 index 00000000000..93a4fb0ca8b --- /dev/null +++ b/.changeset/26548-report-date-range-export.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/platform-objects': patch +--- + +feat(spec): a report declares the period it covers (`dateRange`) and the export of its table (`exportOptions`) + +Clause-②: yes (widening) +Metadata-protocol: added + +`ReportSchema` gains two optional top-level keys. A report that declares neither parses and renders exactly as before. + +- **`dateRange: { dimension?, defaultRange, allowCustomRange }`.** A date window over one dimension of the report's dataset. `defaultRange` and `allowCustomRange` are the dashboard's own declaration, verbatim: the same preset table (`DATE_RANGE_DEFAULT_RANGES`), the same defaults (`this_month`, `true`) and the same rename hints. `dimension` names a dataset **dimension** that declares `type: 'date'`, not a raw object field; omitted, the dataset's one `type: 'date'` dimension is used. The window is a filter, not a bucket. The renderer sends it as `selection.timeDimensions: [{ dimension, dateRange }]` with no `granularity`, which the analytics service already accepts. `dateFilter` / `timeRange` are renamed onto `dateRange`, the dashboard's own spellings. +- **`exportOptions: { formats?, includeHeaders?, fileNamePrefix? }`.** Three members of the list export block, under its key name. `formats` is `csv` / `xlsx` only (`json` and `pdf` are refused with a report-side message). Export is on by default (both formats) and `formats: []` turns it off. `maxRecords` and `streaming` are list-only and are refused with that reason. There is no report permission key: the control is gated on the dataset base object's `ObjectPermission.allowExport`, the axis the list toolbar uses. `allowExport` written on a report is answered with that pointer. +- **Refused on a `joined` report**, on the container and on a block. The blocks may bind different datasets, and the report draws one table per block. +- **No subscription.** `subscription` / `schedule` on a report are answered: there is no scheduled delivery of a report. + +`@objectstack/lint` adds the dataset-aware half as a reference-integrity rule, `validateReportDateRange`. It runs on `os validate`, `os lint` and `os build`, and at the report publish door. Four `error` verdicts, each with an `os explain` entry: `report-date-range-dimension-unknown`, `report-date-range-dimension-not-date`, `report-date-range-dimension-ambiguous` (dimension omitted, two or more date dimensions) and `report-date-range-dimension-none` (dimension omitted, no date dimension). The liveness author-warning lint now walks `reports`, and both keys warn (`liveness-planned-property`) until the Console report page reads them. The report editor form gains a collapsed "Period & export" section, translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES. + +Nothing renders either key yet. The Console report page's period chip and export control are the renderer's half, and until it lands an author declaring either key gets a planned-property warning that says so. diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index dcbbd6b2440..951210c5abb 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -67,6 +67,8 @@ const result = JoinedReportBlockSchema.parse(data); | **order** | `{ by: string; direction?: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first | | **drilldown** | `boolean` | optional (default: `true`) | Click-through to underlying records | | **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | Embedded chart configuration (refused on a joined report, which draws tables only) | +| **dateRange** | `{ dimension?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Report date range: a window over one dataset date dimension (refused on a joined report) | +| **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx'>[]; includeHeaders?: boolean; fileNamePrefix?: string }` | optional | Report export control: `{ formats?, includeHeaders?, fileNamePrefix? }`, csv / xlsx, on by default; who may export is the base object's export permission (refused on a joined report) | | **blocks** | `{ name: string; label?: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this report. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -103,6 +105,22 @@ const result = JoinedReportBlockSchema.parse(data); | **interaction** | `{ tooltips?: boolean; brush?: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` | | **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +### Nested Shape: `Report.dateRange` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **dimension** | `string` | optional | Dataset dimension (declared `type: 'date'`) the window applies to; omitted = the dataset's one date dimension | +| **defaultRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>` | optional (default: `"this_month"`) | Default date range preset | +| **allowCustomRange** | `boolean` | optional (default: `true`) | Allow users to pick a custom date range | + +### Nested Shape: `Report.exportOptions` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **formats** | `Enum<'csv' \| 'xlsx'>[]` | optional | Formats the report's export control offers (default: ['csv', 'xlsx']); an empty list turns the control off | +| **includeHeaders** | `boolean` | optional | Include column headers in the exported file (default true) | +| **fileNamePrefix** | `string` | optional | Download file name prefix — replaces the report label in the generated file name | + ### Nested Shape: `Report.blocks[number]` | Property | Type | Required | Description | diff --git a/content/docs/ui/reports.mdx b/content/docs/ui/reports.mdx index bec709011b8..207d5604abe 100644 --- a/content/docs/ui/reports.mdx +++ b/content/docs/ui/reports.mdx @@ -210,6 +210,67 @@ export const HoursByStatusChartReport = defineReport({ }); ``` +## Period and export + +A non-`joined` report can declare the period it covers and how its table is downloaded. +Both keys are optional: a report that declares neither shows every period, as it always +has. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const PipelineByQuarterReport = defineReport({ + name: 'pipeline_by_quarter', + label: 'Pipeline by Quarter', + type: 'summary', + dataset: 'opportunity_metrics', + rows: ['stage'], + values: ['amount_sum'], + dateRange: { dimension: 'close_date', defaultRange: 'this_quarter' }, + exportOptions: { formats: ['csv', 'xlsx'], fileNamePrefix: 'pipeline' }, +}); +``` + +**`dateRange`** is a window over one **date dimension of the dataset**. `defaultRange` and +`allowCustomRange` are the dashboard's own: the same preset names (`this_month` by default, +`this_quarter`, `last_30_days`, …, and `custom` to open the picker with no preset) and the +same custom-range switch, on by default. + +- **`dimension`** names a dataset dimension that declares `type: 'date'` — a dimension, not a + raw object field. A dimension carries its own relationship path, so a date on a related + record works the same way. +- **Leave `dimension` out** only when the dataset declares exactly one `type: 'date'` + dimension. A dataset with none, or with two or more (a task dataset with `due_date` and + `completed_date`, say), is an error that names what it found. So is a `dimension` the + dataset does not declare, or one that is not `type: 'date'`. `os validate` and `os build` + check this against the dataset, and so does publishing the report. +- **The window is a filter, not a bucket.** It is applied with the report's `runtimeFilter` + and the dataset's own filter. How rows are grouped by date stays with the dimension's + `dateGranularity` and the report's `rows` / `columns`. + +**`exportOptions`** uses the list export's key and member names: `formats`, +`includeHeaders`, `fileNamePrefix`. A report table downloads as `csv` or `xlsx`. Export is on +by default (both formats), and `formats: []` turns it off. The download is the table as drawn, +within the active period and filter, and the file is named for the report label (or +`fileNamePrefix`) and the period. `maxRecords` and `streaming` are list-only, because a report's +rows are already aggregated. + +**Who may export is not a report key.** The export control is offered to a viewer whose +permissions allow **export of the dataset's base object**. That is the permission-set +`allowExport` axis the list toolbar uses (see [Permissions and protection](#permissions-and-protection)). + + +**Declared, not drawn yet.** The Console report page does not read either key yet: there is +no period chip and no export control on it today. Until the renderer ships, `os validate` +warns on both keys so the gap is visible. The declarations are the contract the renderer +implements. + + +What is deliberately **not** offered: a `joined` report refuses both keys, because its blocks +may bind different datasets and it draws one table per block. A report has no subscription or +scheduled delivery, and no `pdf` or `json` export. + ## Making a report reachable A report is not reachable because it exists. Give it a navigation entry on an app: @@ -256,6 +317,10 @@ never on the report — and **reachability** comes from the app navigation entry permissions on the app that carries it. A report is a presentation over a dataset, so it inherits the dataset's enforcement rather than restating it. +Export follows the same principle. A report declares *what* its export offers +(`exportOptions`), never *who* may use it: that is the permission-set `allowExport` grant on +the dataset's base object, which also governs exporting that object's list views. + ## Related - **Schema reference:** [Report](/docs/references/ui/report) — the full generated property diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 35f51238c07..49537cc14cd 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 216 | 205 | 4 | 0 | 7 | +| `ui/` | 217 | 206 | 4 | 0 | 7 | ## `ui/` — sites @@ -41,12 +41,12 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `dataset.zod.ts` | 5 | | `i18n.zod.ts` | 1 | | `page.zod.ts` | 12 | -| `report.zod.ts` | 3 | +| `report.zod.ts` | 4 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | | `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **216** | +| **total** | **217** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 216**, in 4 file(s). +**7 strip of 217**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **216** | +| **total** | **7** | **217** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 55489ae72d6..f5d0097e4a3 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -646,6 +646,18 @@ export { } from './validate-chart-bindings.js'; export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js'; +// [#26548] A report's `dateRange` resolved against its dataset: the one +// `type: 'date'` dimension the period applies to. Beside the chart rule +// because it resolves the same report against the same `stack.datasets`. +export { + validateReportDateRange, + REPORT_DATE_RANGE_DIMENSION_UNKNOWN, + REPORT_DATE_RANGE_DIMENSION_NOT_DATE, + REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS, + REPORT_DATE_RANGE_DIMENSION_NONE, +} from './validate-report-date-range.js'; +export type { ReportDateRangeFinding } from './validate-report-date-range.js'; + // [#14105] The layer BELOW the chart/widget binding rules above: a dataset's // own `include[]`, `dimensions[].field`, `measures[].field` and filter KEYS, // resolved against the object graph. Its base-object half is diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 9ab5746c3fc..6c201e12679 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -2287,3 +2287,30 @@ describe('#26146 — ledger rows below the child level are graded and counted', expect(shipped.has('synth-depth-three')).toBe(false); }); }); + +// ── #26548: a report's period and export are declared before the renderer reads them ── +// +// `report` joined TYPE_COLLECTIONS with its ledger's first warned rows: +// `dateRange` and `exportOptions` are `planned` + `authorWarn` until the +// Console report page reads them. Read off the SHIPPED ledger, so the flip to +// `live` (which drops `authorWarn`) turns the warning half of this block red +// on purpose — rewrite it as a silence pin in that same edit. +describe('report period and export (#26548 — planned until the report renderer reads them)', () => { + const report = (extra: Record = {}) => ({ + reports: [{ name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], ...extra }], + }); + + it('warns on each key, at the report, with the row\'s author hint', () => { + const findings = lintLivenessProperties(report({ dateRange: { dimension: 'close_date' }, exportOptions: { formats: ['csv'] } })); + const planned = findings.filter((f) => f.rule === 'liveness-planned-property'); + expect(planned.map((f) => f.message.match(/`(dateRange|exportOptions)`/)?.[1]).sort()).toEqual(['dateRange', 'exportOptions']); + for (const f of planned) { + expect(f.where).toContain("report 'pipeline'"); + expect(f.hint).toMatch(/^Keep it, but nothing reads it yet: the Console report page draws no /); + } + }); + + it('CONTROL — a report declaring neither key draws no warning', () => { + expect(lintLivenessProperties(report())).toEqual([]); + }); +}); diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index 60014c1e09e..21abfa76317 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -585,6 +585,11 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ // lookup, and it means a future regression that re-deadens a widget key warns // on its own instead of waiting for someone to notice this list again. { type: 'dashboard', key: 'dashboards' }, + // #26548 — `report` joins the day its ledger first warns: `dateRange` and + // `exportOptions` are `planned` + `authorWarn` until the report renderer + // reads them. A flat collection whose top-level keys are the ledger's props, + // so the flat walk below is the right shape for it. + { type: 'report', key: 'reports' }, ]; /** diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index da920f347f6..6a120c374be 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -45,6 +45,10 @@ describe('reference-integrity suite — membership', () => { // Placed beside the member that resolves the same page's field names. 'validatePageVisualizationBindings', 'validateChartBindings', + // [#26548] The same report's `dateRange.dimension`, resolved against the + // same dataset's date dimensions — placed beside the chart member that + // resolves the rest of that report's bindings. + 'validateReportDateRange', // [#14105] The dataset's OWN references, one level below the two binding // rules above it — the conscious edit this written-out list exists to // force, placed so the family reports top-down. diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index cc3e3f930a9..66a98277e7c 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -109,6 +109,7 @@ import { validateActionDispatchContract } from './validate-action-dispatch-contr import { validatePageFieldBindings } from './validate-page-field-bindings.js'; import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js'; import { validateChartBindings } from './validate-chart-bindings.js'; +import { validateReportDateRange } from './validate-report-date-range.js'; import { validateDatasetReferences } from './validate-dataset-references.js'; import { validateMappingTargetFields } from './validate-mapping-target-fields.js'; import { validateNavAccess } from './validate-nav-access.js'; @@ -438,6 +439,23 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset // with `rows`/`values` — 0 findings, with a lit synthetic probe refused. { name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings }, + // [#26548] The same report, one key on: `dateRange.dimension` resolved + // against the bound dataset's `type: 'date'` dimensions (named, or the ONE + // candidate when omitted). Directly after its sibling because the two read + // the same `stack.reports` against the same `stack.datasets`, and this one + // skips a dataset that resolves to nothing — the sibling's + // `chart-dataset-unknown` reports that typo once. + // + // `report` joins the `['flow']` default for the sibling's reason, unchanged: + // `stack.datasets` is the one collection it resolves into and the per-write + // report snapshot carries it, so there is no missing-collection + // false-positive channel. The publish door is the only door a Studio or + // MCP/AI author has, and a window the renderer cannot apply should be + // refused there, not stored. A refusal widening on a NEW key only: MEASURED + // over the shipped report corpus (showcase 4, todo 5) before crossing — no + // report declares `dateRange`, so 0 findings by construction, and the door + // test's lit probes are refused (`runtime-gate.inert-type-writes.test.ts`). + { name: 'validateReportDateRange', runtimeTypes: ['flow', 'report'], run: validateReportDateRange }, // [#14105] One level BELOW the two members above it. `validateChartBindings` // and `validateWidgetBindings` resolve a presentation's binding against the // dataset (#7529/#8902); this one resolves the DATASET's own references — diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 61a7653c826..fc1e2e874a6 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -4984,6 +4984,48 @@ const PICKLIST_REFERENCE_UNVERIFIED_EXPLANATION: RuleExplanation = { * Every rule explanation this package ships, keyed by rule id. `os explain * ` reads this table and nothing else. */ +// ── Report date range (`validate-report-date-range.ts`) ────────────────────── + +const REPORT_DATE_RANGE_SHARED_PARAGRAPHS: readonly string[] = [ + 'A report\'s `dateRange` is a period over ONE dimension of the dataset the report binds, and that ' + + 'dimension must declare `type: \'date\'`: the analytics compiler types exactly those as time ' + + 'dimensions, and any other dimension, `type` absent included, as a string axis that cannot carry a ' + + 'date window. A dimension over a date column that does not say `type: \'date\'` is not a date ' + + 'dimension.', + 'Name it in `dateRange.dimension`. When `dimension` is omitted, the dataset\'s one `type: \'date\'` ' + + 'dimension is used; a dataset that declares none, or two or more, is an error naming what it found, ' + + 'because the period would otherwise apply to no column, or to one nobody chose. That is the rule a ' + + 'dashboard\'s omitted `compareTo.dimension` follows.', + 'The report schema parses without the dataset, so it checks only the shape of `dateRange`; this rule ' + + 'is the dataset-aware half and runs on `os validate`, `os build` and when a report is published. Not ' + + 'judged here: a report whose `dataset` resolves to nothing (`chart-dataset-unknown` reports that ' + + 'once), and a `joined` report, whose `dateRange` the schema refuses.', +]; + +const REPORT_DATE_RANGE_DIMENSION_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'report-date-range-dimension-unknown', + covers: 'which dimension a report date range applies to', + paragraphs: REPORT_DATE_RANGE_SHARED_PARAGRAPHS, +}; + +const REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION: RuleExplanation = { + rule: 'report-date-range-dimension-not-date', + covers: 'what makes a dataset dimension a date dimension', + paragraphs: REPORT_DATE_RANGE_SHARED_PARAGRAPHS, +}; + +const REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION: RuleExplanation = { + rule: 'report-date-range-dimension-ambiguous', + covers: 'how an omitted date range dimension is resolved', + paragraphs: REPORT_DATE_RANGE_SHARED_PARAGRAPHS, +}; + +const REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION: RuleExplanation = { + rule: 'report-date-range-dimension-none', + covers: 'how an omitted date range dimension is resolved', + paragraphs: REPORT_DATE_RANGE_SHARED_PARAGRAPHS, +}; + export const RULE_EXPLANATIONS: Readonly> = Object.freeze({ [FIELD_NO_CONSUMERS_EXPLANATION.rule]: FIELD_NO_CONSUMERS_EXPLANATION, [SECURITY_OWD_UNSET_EXPLANATION.rule]: SECURITY_OWD_UNSET_EXPLANATION, @@ -5176,6 +5218,10 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [DOCS_NAV_TARGET_EXPLANATION.rule]: DOCS_NAV_TARGET_EXPLANATION, [DOCS_FRONTMATTER_TAGS_EXPLANATION.rule]: DOCS_FRONTMATTER_TAGS_EXPLANATION, [PICKLIST_REFERENCE_UNVERIFIED_EXPLANATION.rule]: PICKLIST_REFERENCE_UNVERIFIED_EXPLANATION, + [REPORT_DATE_RANGE_DIMENSION_UNKNOWN_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_UNKNOWN_EXPLANATION, + [REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION, + [REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION, + [REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION, }); /** The explanation for `rule`, or `undefined` when the rule has none. Exact id match. */ diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 47397e6ee27..a9ec2268c8e 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -474,6 +474,59 @@ describe('#19542 — the `report` door (three rules, one document)', () => { }); }); +describe('#26548 — the `report` door judges a date range against the dataset it binds', () => { + // The door's own snapshot shape, with a dataset that declares two date + // dimensions (`examples/app-todo`'s task dataset has the same pair) and one + // that declares none. + const DATED = { + objects: OBJECTS, + datasets: [ + ...DATASETS, + { + name: 'acme_invoice_dated', + label: 'Invoice Metrics (dated)', + object: 'acme_invoice', + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'issued_on', label: 'Issued', field: 'issued_on', type: 'date' }, + { name: 'paid_on', label: 'Paid', field: 'paid_on', type: 'date' }, + ], + measures: [{ name: 'total_sum', label: 'Total', aggregate: 'sum', field: 'total' }], + }, + ], + }; + const report = (over: Record) => ({ + name: 'acme_dated_report', + label: 'Invoices by Period', + type: 'summary', + dataset: 'acme_invoice_dated', + rows: ['status'], + values: ['total_sum'], + ...over, + }); + + it.each([ + ['a dimension the dataset does not declare', { dateRange: { dimension: 'due_on' } }, 'report-date-range-dimension-unknown'], + ['a dimension that is not a date', { dateRange: { dimension: 'status' } }, 'report-date-range-dimension-not-date'], + ['no dimension over two date dimensions', { dateRange: {} }, 'report-date-range-dimension-ambiguous'], + ['no dimension over a dataset with none', { dataset: 'acme_invoice_metrics', dateRange: {} }, 'report-date-range-dimension-none'], + ] as const)('⭐ LIT — %s is REFUSED', (_label, over, rule) => { + const result = runRuntimeAuthoringRules({ type: 'report', item: report(over), context: DATED }); + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + expect(ruleIds(result.errors), dump(result)).toEqual([rule]); + }); + + it('⭐ CONTROL — a date range naming one date dimension publishes clean', () => { + const result = runRuntimeAuthoringRules({ + type: 'report', + item: report({ dateRange: { dimension: 'issued_on', defaultRange: 'this_quarter' }, exportOptions: { formats: ['csv'] } }), + context: DATED, + }); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); +}); + // ─── Group A · `skill` — outside the door BY DESIGN, and these pins hold it ── describe('#19527 — `skill` stays outside the door by design: tool references are a whole-stack verdict', () => { @@ -875,7 +928,7 @@ describe('#19542 — DARK: what the five crossings did NOT widen', () => { } }); - it('a `report` write reaches exactly ONE suite member, and a `skill` write none', () => { + it('a `report` write reaches exactly TWO suite members, and a `skill` write none', () => { // The entry-level `runtimeTypes` says which WRITES dispatch the suite; the // per-member `runtimeTypes` says which MEMBERS judge that snapshot. Asked // against the real table so a member widened without this file noticing @@ -883,7 +936,7 @@ describe('#19542 — DARK: what the five crossings did NOT widen', () => { const membersFor = (type: string) => REFERENCE_INTEGRITY_RULES.filter((m) => (m.runtimeTypes ?? ['flow']).includes(type)).map((m) => m.name); - expect(membersFor('report')).toEqual(['validateChartBindings']); + expect(membersFor('report')).toEqual(['validateChartBindings', 'validateReportDateRange']); // Outside the door by design (ruling `5791822697`) — the entry does not // dispatch a skill write and no member claims one, so the two halves // agree. See the `skill` block above. diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index df2394ff776..c92147b0087 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -687,7 +687,12 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // accepts `expression` on every type and the engine computes and stores it // on a non-formula field, so the formula-only row hid a live key; the row // is now shown for every type, as the field form's own row already was. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(88); + // It is 89 today, an ADDITION of ONE: #26548 offered the report form's + // "Period & export" section, gated like the container `chart` row on + // `data.type != 'joined'` (`ReportSchema` refuses `dateRange` and + // `exportOptions` on a joined report). It reaches the literal-comparison + // census below. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(89); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -804,7 +809,9 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `flow`, the third type its parse accepts, and respelled the gate // `data.type in ['api','script','flow']` — an `in`-list, outside this rule, // so its two `==` literal comparisons left the census. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(63); + // It is 64 today: #26548's report "Period & export" section is gated on + // `data.type != 'joined'`, a single-quoted literal comparison. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(64); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/lint/src/validate-report-date-range.test.ts b/packages/lint/src/validate-report-date-range.test.ts new file mode 100644 index 00000000000..bb4b754e0b3 --- /dev/null +++ b/packages/lint/src/validate-report-date-range.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateReportDateRange as validateReportDateRangeUnrecorded, + REPORT_DATE_RANGE_DIMENSION_UNKNOWN, + REPORT_DATE_RANGE_DIMENSION_NOT_DATE, + REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS, + REPORT_DATE_RANGE_DIMENSION_NONE, +} from './validate-report-date-range.js'; +import { explainRule } from './rule-explanations.js'; +import { validateReferenceIntegrity } from './reference-integrity-suite.js'; + +// Every call records what it fired; the last cases hold each recorded verdict +// to one line of at most 200 characters (the one-verdict-per-finding shape). +const fired: Array<{ rule: string; message: string; hint: string }> = []; +const validateReportDateRange: typeof validateReportDateRangeUnrecorded = (...args) => { + const findings = validateReportDateRangeUnrecorded(...args); + fired.push(...findings); + return findings; +}; + +/** + * `examples/app-todo`'s task dataset shape: two `type: 'date'` dimensions, so + * a report on it has to name which one its period applies to. + */ +const TASK_METRICS = { + name: 'task_metrics', + object: 'todo_task', + dimensions: [ + { name: 'status', field: 'status', type: 'string' }, + { name: 'owner', field: 'owner', type: 'lookup' }, + { name: 'due_date', field: 'due_date', type: 'date' }, + { name: 'completed_date', field: 'completed_date', type: 'date' }, + // A date COLUMN that does not declare `type: 'date'` is a string axis. + { name: 'created', field: 'created_at' }, + ], + measures: [{ name: 'task_count', aggregate: 'count' }], +}; + +const ONE_DATE = { + name: 'deal_metrics', + object: 'deal', + dimensions: [ + { name: 'stage', field: 'stage', type: 'string' }, + { name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'quarter' }, + ], + measures: [{ name: 'amount_sum', aggregate: 'sum', field: 'amount' }], +}; + +const NO_DATE = { + name: 'region_metrics', + object: 'deal', + dimensions: [{ name: 'region', field: 'region', type: 'string' }], + measures: [{ name: 'deal_count', aggregate: 'count' }], +}; + +const stack = (report: Record) => ({ + datasets: [TASK_METRICS, ONE_DATE, NO_DATE], + reports: [{ name: 'r', label: 'R', type: 'summary', rows: ['status'], values: ['task_count'], dataset: 'task_metrics', ...report }], +}); + +describe('validateReportDateRange — a named dimension', () => { + it('is clean when it names a `type: \'date\'` dimension', () => { + expect(validateReportDateRange(stack({ dateRange: { dimension: 'due_date' } }))).toEqual([]); + }); + + it('errors on a name the dataset does not declare, listing its date dimensions', () => { + const [f, ...rest] = validateReportDateRange(stack({ dateRange: { dimension: 'due_dat' } })); + expect(rest).toEqual([]); + expect(f!.rule).toBe(REPORT_DATE_RANGE_DIMENSION_UNKNOWN); + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('reports[0].dateRange.dimension'); + expect(f!.where).toBe('report "r" · dateRange'); + expect(f!.hint).toContain('(completed_date, due_date)'); + expect(f!.hint).toContain('Did you mean "due_date"?'); + }); + + it('never suggests a dimension the window would refuse — the nearest name among DATE dimensions only', () => { + // `statu` is one edit from `status`, which is not a date dimension. + const [f] = validateReportDateRange(stack({ dateRange: { dimension: 'statu' } })); + expect(f!.rule).toBe(REPORT_DATE_RANGE_DIMENSION_UNKNOWN); + expect(f!.hint).not.toContain('"status"'); + }); + + it.each([['status'], ['owner'], ['created']])('errors on `%s`, a declared dimension that is not a date', (dimension) => { + const [f, ...rest] = validateReportDateRange(stack({ dateRange: { dimension } })); + expect(rest).toEqual([]); + expect(f!.rule).toBe(REPORT_DATE_RANGE_DIMENSION_NOT_DATE); + expect(f!.path).toBe('reports[0].dateRange.dimension'); + expect(f!.message).toContain(`dimension "${dimension}" of dataset "task_metrics"`); + }); +}); + +describe('validateReportDateRange — an omitted dimension', () => { + it('is clean when the dataset declares exactly one date dimension', () => { + expect(validateReportDateRange(stack({ dataset: 'deal_metrics', rows: ['stage'], values: ['amount_sum'], dateRange: {} }))).toEqual([]); + }); + + it('errors when the dataset declares several, naming them', () => { + const [f, ...rest] = validateReportDateRange(stack({ dateRange: { defaultRange: 'this_quarter' } })); + expect(rest).toEqual([]); + expect(f!.rule).toBe(REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS); + expect(f!.path).toBe('reports[0].dateRange'); + expect(f!.message).toContain('declares 2 date dimensions (completed_date, due_date)'); + expect(f!.hint).toContain("`dimension: 'completed_date'`"); + }); + + it('errors when the dataset declares none', () => { + const [f, ...rest] = validateReportDateRange(stack({ dataset: 'region_metrics', rows: ['region'], values: ['deal_count'], dateRange: {} })); + expect(rest).toEqual([]); + expect(f!.rule).toBe(REPORT_DATE_RANGE_DIMENSION_NONE); + expect(f!.path).toBe('reports[0].dateRange'); + }); +}); + +describe('validateReportDateRange — what it does not judge', () => { + it('is silent on a report with no `dateRange`', () => { + expect(validateReportDateRange(stack({}))).toEqual([]); + }); + + it('is silent on a dataset that resolves to nothing — `chart-dataset-unknown` reports it, once', () => { + const s = stack({ dataset: 'no_such_dataset', dateRange: {} }); + expect(validateReportDateRange(s)).toEqual([]); + const suite = validateReferenceIntegrity(s); + expect(suite.filter((f) => f.rule === 'chart-dataset-unknown')).toHaveLength(1); + expect(suite.filter((f) => f.rule.startsWith('report-date-range-'))).toEqual([]); + }); + + it('is silent on a joined report — the schema refuses its `dateRange`', () => { + expect(validateReportDateRange(stack({ type: 'joined', dataset: undefined, dateRange: {} }))).toEqual([]); + }); + + it('never throws on malformed input', () => { + expect(validateReportDateRange({ reports: [null, 3, { dateRange: 'x' }], datasets: [null] } as never)).toEqual([]); + expect(validateReportDateRange(null as never)).toEqual([]); + }); + + it('runs inside the reference-integrity suite (os validate / os lint / os build)', () => { + const suite = validateReferenceIntegrity(stack({ dateRange: {} })); + expect(suite.map((f) => f.rule)).toContain(REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS); + }); +}); + +describe('validateReportDateRange — verdict shape', () => { + it('every fired verdict is one line of at most 200 characters, and every id has an explanation', () => { + expect(fired.length).toBeGreaterThan(0); + for (const f of fired) { + expect(f.message, f.rule).not.toContain('\n'); + expect(f.message.length, f.message).toBeLessThanOrEqual(200); + expect(f.hint, f.rule).not.toContain('\n'); + } + for (const rule of [ + REPORT_DATE_RANGE_DIMENSION_UNKNOWN, + REPORT_DATE_RANGE_DIMENSION_NOT_DATE, + REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS, + REPORT_DATE_RANGE_DIMENSION_NONE, + ]) { + expect(fired.some((f) => f.rule === rule), `${rule} fired in this file`).toBe(true); + expect(explainRule(rule)?.rule, rule).toBe(rule); + } + }); +}); diff --git a/packages/lint/src/validate-report-date-range.ts b/packages/lint/src/validate-report-date-range.ts new file mode 100644 index 00000000000..bb7e97d6b7d --- /dev/null +++ b/packages/lint/src/validate-report-date-range.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0021 — semantic layer] A report's `dateRange` resolves to ONE date + * dimension of the dataset it binds (#26548; ruling 6408567749 on #25873, + * letter B: "applied when the dataset has a time dimension"). + * + * `ReportSchema.dateRange` is `{ dimension?, defaultRange, allowCustomRange }`. + * The schema parses a report WITHOUT its dataset, so it can check only the + * shape; which dimension the window lands on is a question about the dataset, + * and it is answered here, beside `validate-chart-bindings.ts`, which resolves + * the same report against the same `stack.datasets`. + * + * ## What "a date dimension" means + * + * A dataset dimension declaring `type: 'date'`. The analytics compiler types + * exactly those as time dimensions and every other one — `type` absent + * included — as a string axis (`dimensionType` in + * `services/service-analytics/src/dataset-compiler.ts`), so a dimension over a + * date column that does not say `type: 'date'` cannot carry a date window. + * + * ## The four verdicts, all `error` + * + * - `report-date-range-dimension-unknown` — `dimension` names no dimension + * of the bound dataset. + * - `report-date-range-dimension-not-date` — it names one that does not + * declare `type: 'date'`. + * - `report-date-range-dimension-ambiguous` — `dimension` is omitted and the + * dataset declares two or more date dimensions, so nothing says which one + * the window applies to (`examples/app-todo`'s task dataset declares + * `due_date` and `completed_date`). + * - `report-date-range-dimension-none` — `dimension` is omitted and the + * dataset declares none, so the period has no column to apply to. + * + * The omitted-dimension rule is the one the dashboard executor applies to an + * omitted `compareTo.dimension`: exactly one candidate is used, zero or + * several is a loud error naming what it found. Every case is an `error` + * because each one is a window the renderer cannot apply — a report whose + * period selector would either fail its query or filter a column nobody + * chose. + * + * ## Not judged here + * + * - A report whose `dataset` resolves to nothing: `chart-dataset-unknown` + * owns that, once; repeating it here would report one typo twice. + * - A `joined` report: `ReportSchema` refuses a container `dateRange` and a + * block declares none, so there is no binding to resolve. + * - The window's `defaultRange` / `allowCustomRange`: shape, owned by the + * schema (the dashboard's preset table, verbatim). + */ + +import { recordsOf, suggestName } from './object-graph.js'; +import type { AnyRec } from './page-walk.js'; + +export const REPORT_DATE_RANGE_DIMENSION_UNKNOWN = 'report-date-range-dimension-unknown'; +export const REPORT_DATE_RANGE_DIMENSION_NOT_DATE = 'report-date-range-dimension-not-date'; +export const REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS = 'report-date-range-dimension-ambiguous'; +export const REPORT_DATE_RANGE_DIMENSION_NONE = 'report-date-range-dimension-none'; + +export interface ReportDateRangeFinding { + severity: 'error'; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `report "pipeline" · dateRange`. */ + where: string; + /** Config path, e.g. `reports[2].dateRange.dimension`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function list(names: Iterable): string { + const all = [...names].sort(); + return all.length ? all.join(', ') : '(none)'; +} + +/** One dataset's dimensions, name → whether it declares `type: 'date'`. */ +type DimensionTypes = Map; + +function indexDatasetDimensions(stack: AnyRec): Map { + const out = new Map(); + for (const ds of recordsOf(stack.datasets)) { + const name = strName(ds.name); + if (!name) continue; + const dims: DimensionTypes = new Map(); + for (const d of recordsOf(ds.dimensions)) { + const n = strName(d.name); + if (n) dims.set(n, d.type === 'date'); + } + out.set(name, dims); + } + return out; +} + +export function validateReportDateRange(stack: AnyRec): ReportDateRangeFinding[] { + const findings: ReportDateRangeFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const reports = recordsOf(stack.reports); + if (reports.length === 0) return findings; + const datasets = indexDatasetDimensions(stack); + + for (let ri = 0; ri < reports.length; ri++) { + const report = reports[ri]; + if (!isRec(report) || report.type === 'joined' || !isRec(report.dateRange)) continue; + const dsName = strName(report.dataset); + const dims = dsName ? datasets.get(dsName) : undefined; + // Unbound or unresolvable: the shape rule and `chart-dataset-unknown` own it. + if (!dsName || !dims) continue; + + const reportName = strName(report.name) ?? `#${ri}`; + const where = `report "${reportName}" · dateRange`; + const path = `reports[${ri}].dateRange`; + const dateDims = [...dims].filter(([, isDate]) => isDate).map(([n]) => n); + const dimension = strName(report.dateRange.dimension); + + if (dimension) { + if (!dims.has(dimension)) { + findings.push({ + severity: 'error', + rule: REPORT_DATE_RANGE_DIMENSION_UNKNOWN, + where, + path: `${path}.dimension`, + message: `"${dimension}" is not a dimension of dataset "${dsName}", so the report's date range has no column to apply to.`, + hint: + `Name a date dimension of "${dsName}" (${list(dateDims)}), or declare one with \`type: 'date'\`.` + + suggestName(dimension, dateDims), + }); + } else if (!dims.get(dimension)) { + findings.push({ + severity: 'error', + rule: REPORT_DATE_RANGE_DIMENSION_NOT_DATE, + where, + path: `${path}.dimension`, + message: `dimension "${dimension}" of dataset "${dsName}" does not declare \`type: 'date'\`, so it is not a time dimension and cannot carry a date window.`, + hint: `Declare \`type: 'date'\` on that dimension if it is a date, or name a date dimension (${list(dateDims)}).`, + }); + } + continue; + } + + if (dateDims.length > 1) { + findings.push({ + severity: 'error', + rule: REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS, + where, + path, + message: `dataset "${dsName}" declares ${dateDims.length} date dimensions (${list(dateDims)}) and \`dimension\` names none, so the window has no single column.`, + hint: `Set \`dateRange.dimension\` to the one the period applies to, e.g. \`dimension: '${[...dateDims].sort()[0]}'\`.`, + }); + } else if (dateDims.length === 0) { + findings.push({ + severity: 'error', + rule: REPORT_DATE_RANGE_DIMENSION_NONE, + where, + path, + message: `dataset "${dsName}" declares no \`type: 'date'\` dimension, so the report's date range has no column to apply to.`, + hint: `Declare \`type: 'date'\` on the dataset's date dimension, or remove \`dateRange\`.`, + }); + } + } + + return findings; +} diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index a20b0d59916..b08b655c826 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -331,7 +331,9 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * 12 → 13 the same way when it gained `permissionSets` (ADR-0131 D3/D4, * the sets a position distributes). `flow` moved 23 → 24 the same way * when it gained `requiredPermissions` (who may start the flow, checked at - * every start door). + * every start door). `report` moved 21 → 23 the same way when it gained + * `dateRange` and `exportOptions` (#26548, the report's period and the + * download of its table). */ // [#25767] The ≤16.x tombstones aged out of the declared sets at 18 (a kit // lives one major past the major it retired in), so each count below moved @@ -343,7 +345,7 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // 17 → 16 (`triggerPhrases`). The live sets are unchanged. const CARD_PROPERTY_COUNTS: Record = { agent: 24, app: 22, dashboard: 20, dataset: 16, field: 79, flow: 22, - hook: 22, object: 48, page: 26, position: 13, report: 21, skill: 16, tool: 14, + hook: 22, object: 48, page: 26, position: 13, report: 23, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index ae59c273659..4858da00d3a 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1895,6 +1895,10 @@ export const enMetadataForms: NonNullable = { filter_and_chart: { label: "Filter & chart", description: "Render-time scope filter and chart presentation." + }, + period_and_export: { + label: "Period & export", + description: "The default period of the report and the formats its table can be downloaded in." } }, fields: { @@ -1983,6 +1987,14 @@ export const enMetadataForms: NonNullable = { chart: { label: "Chart", helpText: "Chart config (type, legend, colors)" + }, + dateRange: { + label: "Date Range", + helpText: "Default period and custom-range switch. The dimension must be a date dimension of the dataset; leave it empty when the dataset has exactly one." + }, + exportOptions: { + label: "Export Options", + helpText: "Download formats (csv, xlsx) and file name. Who may download is the export permission on the dataset object." } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 9efad055ce2..5e296dd3dc9 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1895,6 +1895,10 @@ export const esESMetadataForms: NonNullable = filter_and_chart: { label: "Filtro y gráfico", description: "Filtros a nivel de informe y presentación del gráfico." + }, + period_and_export: { + label: "Periodo y exportación", + description: "El periodo predeterminado del informe y los formatos en los que se puede descargar su tabla." } }, fields: { @@ -1983,6 +1987,14 @@ export const esESMetadataForms: NonNullable = chart: { label: "Gráfico", helpText: "Configuración de gráfico (type, legend, colors)" + }, + dateRange: { + label: "Rango de fechas", + helpText: "Periodo predeterminado e interruptor de rango personalizado. La dimensión debe ser una dimensión de fecha del conjunto de datos; déjela vacía cuando el conjunto de datos tenga exactamente una." + }, + exportOptions: { + label: "Opciones de exportación", + helpText: "Formatos de descarga (csv, xlsx) y nombre de archivo. Quién puede descargar lo determina el permiso de exportación del objeto del conjunto de datos." } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 6f89cf2a49e..9008e9c9c9c 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1895,6 +1895,10 @@ export const jaJPMetadataForms: NonNullable = filter_and_chart: { label: "フィルターとチャート", description: "レポートレベルのフィルターとチャート表示。" + }, + period_and_export: { + label: "期間とエクスポート", + description: "レポートの既定の期間と、表をダウンロードできる形式。" } }, fields: { @@ -1983,6 +1987,14 @@ export const jaJPMetadataForms: NonNullable = chart: { label: "チャート", helpText: "チャート設定(type, legend, colors)" + }, + dateRange: { + label: "日付範囲", + helpText: "既定の期間とカスタム範囲の切り替え。ディメンションはデータセットの日付ディメンションである必要があります。データセットに日付ディメンションが 1 つだけの場合は空欄のままにします。" + }, + exportOptions: { + label: "エクスポートオプション", + helpText: "ダウンロード形式(csv, xlsx)とファイル名。ダウンロードできるユーザーは、データセットのオブジェクトのエクスポート権限で決まります。" } } }, diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index f2e3b4580b2..5195f7863e7 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1223,7 +1223,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 683 since #25760 (ADR-0055 §3 amendment): the object form's `sharing` // composite — the block, its `controlledByParent` sub-block and the // `ownerMayEdit` toggle — three row labels, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(683); + // 686 since #26548: the report form's "Period & export" section and its + // `dateRange` / `exportOptions` composites — the section label and two + // row labels, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(686); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts index b94b6f932b4..43117259709 100644 --- a/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts @@ -80,6 +80,13 @@ // locales, so the population went 45 → 44, the field leaves 37 → 36 and the // inward negatives 39 → 38, and none of the six decided rows moved.) // +// (#26548 added the "Period & export" section, with its `dateRange` and +// `exportOptions` composites, authored in all three locales when they landed: +// 6 new leaves, all non-echoing. The population went 44 → 50, the section +// leaves 8 → 10, the field leaves 36 → 40 and the inward negatives 38 → 44; the +// counts in the prose above are this round's base, and none of the six decided +// rows moved.) +// // ## ⚠️⚠️ The phantom-translation trap — and why this family is the SHARP case // // `reportForm` declares NO label on `drilldown` and NO label on `runtimeFilter` @@ -830,7 +837,7 @@ describe('#19403 round 9 — the population, DERIVED from `reportForm`', () => { 'the catalog holds a report field reportForm does not declare', ).toEqual([]); const declaredSections = FORM_SECTIONS.map((s) => sectionName(s)); - expect(declaredSections).toEqual(['basics', 'dataset_binding', 'joined_blocks', 'filter_and_chart']); + expect(declaredSections).toEqual(['basics', 'dataset_binding', 'joined_blocks', 'filter_and_chart', 'period_and_export']); expect( Object.keys(subtree.sections ?? {}).filter((k) => !declaredSections.includes(k)), 'the catalog holds a report section reportForm does not declare', @@ -841,9 +848,9 @@ describe('#19403 round 9 — the population, DERIVED from `reportForm`', () => { // `report.sections.*` is written into #19403's body as a population NOTHING // covers — 「承接者:无」. This walk covers it, and the count is asserted so // a walk that quietly stopped at `fields` would go red. - expect(PANEL_LEAVES.length).toBe(44); - expect(PANEL_LEAVES.filter((l) => l.kind === 'section').length).toBe(8); - expect(PANEL_LEAVES.filter((l) => l.kind === 'field').length).toBe(36); + expect(PANEL_LEAVES.length).toBe(50); + expect(PANEL_LEAVES.filter((l) => l.kind === 'section').length).toBe(10); + expect(PANEL_LEAVES.filter((l) => l.kind === 'field').length).toBe(40); expect( PANEL_LEAVES.some((l) => l.kind === 'section' && l.path === 'dataset_binding' && l.prop === 'description'), 'the section description this round decides is not in the population', @@ -902,13 +909,13 @@ describe('#19403 round 9 — the population, DERIVED from `reportForm`', () => { ); }); - it('⭐ DARK, INWARD — 38 of this population\'s own 44 leaves come back NON-ECHOING', () => { + it('⭐ DARK, INWARD — 44 of this population\'s own 50 leaves come back NON-ECHOING', () => { // A hand-list of six echoes can only ever produce positives. This derivation - // produces 38 negatives from the same predicate in the same run, including + // produces 44 negatives from the same predicate in the same run, including // the twin `blocks.runtimeFilter` the runtimeFilter row is copied from. const decided = new Set(DECISIONS.map(idOf)); const negatives = PANEL_LEAVES.filter((l) => !decided.has(idOf(l))); - expect(negatives.length).toBe(38); + expect(negatives.length).toBe(44); for (const leaf of negatives) { for (const [locale, forms] of TRANSLATED_LOCALES) { expect(catalogLeaf(forms, leaf), `${locale} ${idOf(leaf)} is an echo, not a control`).not.toBe(leaf.en); diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index e799e40b0dc..219ac9d6995 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1895,6 +1895,10 @@ export const zhCNMetadataForms: NonNullable = filter_and_chart: { label: "筛选与图表", description: "条件与图表展示" + }, + period_and_export: { + label: "时间段与导出", + description: "报表的默认时间段,以及其表格可下载的格式" } }, fields: { @@ -1983,6 +1987,14 @@ export const zhCNMetadataForms: NonNullable = chart: { label: "图表", helpText: "图表类型与配置" + }, + dateRange: { + label: "日期范围", + helpText: "默认时间段与自定义范围开关。维度必须是数据集的日期维度;数据集只有一个日期维度时可留空" + }, + exportOptions: { + label: "导出选项", + helpText: "下载格式(csv、xlsx)与文件名。谁可以下载由数据集对象上的导出权限决定" } } }, diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 607e06186fb..76e4b088ddb 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1238,8 +1238,10 @@ "ui/Report:chart", "ui/Report:columns", "ui/Report:dataset", + "ui/Report:dateRange", "ui/Report:description", "ui/Report:drilldown", + "ui/Report:exportOptions", "ui/Report:label", "ui/Report:name", "ui/Report:order", diff --git a/packages/spec/liveness/report.json b/packages/spec/liveness/report.json index 2cc0e51993c..19b41b0a785 100644 --- a/packages/spec/liveness/report.json +++ b/packages/spec/liveness/report.json @@ -53,6 +53,60 @@ "verifiedAt": "2026-09-27", "note": "objectui: DatasetReportChart plots `chart.xAxis` (dataset dimension) × `chart.yAxis` (measure) via useDatasetRows, honoring `type`/`title` (framework#1890/#3441). Supersedes the 2026-06 audit's DEAD finding. Sub-key `groupBy` stays experimental (its own describe marker) — the dataset renderer plots a single series and never reads it. Live on NON-joined reports only (#20161, re-read at the `.objectui-sha` pin f8a9d0fb): the one `report.chart` read (DatasetReportRenderer.tsx:1557) sits after the joined branch's return (:1462-1524), so on a `type: 'joined'` report the schema's refinement REFUSES `chart` rather than accepting a key nothing reads. A `joined` report draws no chart (packages/spec/src/ui/report.zod.ts, the joined-chart prescription, #20161; read point moved out of spec source, #23434): at objectui @f8a9d0fb0596 packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer the joined branch draws each block as a `DatasetMatrixTable` / `DatasetReportTable` and returns before the one `report.chart` read, and no renderer reads a block's `chart`." }, + "dateRange": { + "status": "planned", + "verifiedAt": "2026-10-27", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: the Console report page draws no date-range chip, so the report still shows every period until the report renderer reads this key.", + "note": "PLANNED RECORD (#26548, the spec half of ruling 6408567749 on #25873, letter B). The report period `{ dimension?, defaultRange, allowCustomRange }`: `defaultRange` / `allowCustomRange` are the dashboard declaration verbatim (DATE_RANGE_DEFAULT_RANGES, defaults this_month / true, held equal by report-date-range-export.test.ts); `dimension` names a dataset dimension that declares `type: date` (omitted = the dataset declares exactly one). The four dataset-aware cases are judged by the `@objectstack/lint` rule validate-report-date-range.ts at os validate / os build and at the report publish door. Refused on a `joined` container. PLANNED, not live: no renderer reads it. The report page posts its selection through `useDatasetRows` in ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer with no `timeDimensions` entry, and no source under ui/packages/plugin-report/src/ names `dateRange` (0 hits outside tests). The renderer contract is the TSDoc on `ReportSchema.dateRange`: the active window rides `selection.timeDimensions: [{ dimension, dateRange }]` with no `granularity` (a filter window ANDed with `runtimeFilter`), which the analytics service already accepts. Carrier: #25873, the ui renderer card, Blocked-by this one. Flip to `live` on the commit that cites the renderer read point, and drop `authorWarn` / `authorHint` in that same edit.", + "children": { + "dimension": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/ui/report.zod.ts#ReportSchema (declares `dimension` on the closed `dateRange` block)", + "note": "The dataset dimension the window applies to. Planned with its container: no renderer reads it. Its dataset-aware checks are live in lint (validate-report-date-range.ts: unknown, not a date, omitted with several date dimensions, omitted with none)." + }, + "defaultRange": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/ui/report.zod.ts#ReportSchema (declares `defaultRange` over DATE_RANGE_DEFAULT_RANGES, default this_month)", + "note": "The preset the report opens on, the dashboard declaration verbatim. Planned with its container: no renderer reads it." + }, + "allowCustomRange": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/ui/report.zod.ts#ReportSchema (declares `allowCustomRange`, default true)", + "note": "Whether the viewer may pick a custom range, the dashboard declaration verbatim. Planned with its container: no renderer reads it." + } + } + }, + "exportOptions": { + "status": "planned", + "verifiedAt": "2026-10-27", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: the Console report page draws no export control, so the report table cannot be downloaded until the report renderer reads this key.", + "note": "PLANNED RECORD (#26548, the spec half of ruling 6408567749 on #25873, letter B). The report export control `{ formats?, includeHeaders?, fileNamePrefix? }`: three members of the list export block (ReportExportOptionsSchema in packages/spec/src/ui/list-view-export-options.ts), formats `csv` / `xlsx` only, on by default (absent = both, `formats: []` = off). Who may export is NOT a report key: the control is gated on the viewer having `export` in the effective apiOperations of the dataset base object (ObjectPermission.allowExport, the predicate the list toolbar uses). Refused on a `joined` container. PLANNED, not live: no renderer reads it. The report page (ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer) draws no export control, no source under ui/packages/plugin-report/src/ names `exportOptions` (0 hits outside tests), and the client-side CSV writer in ui/packages/plugin-report/src/ReportExportEngine.ts serves the legacy presentation schema, not ReportSchema. The renderer writes XLSX in the browser: the list XLSX path is the server record stream, which cannot carry aggregated report rows. Carrier: #25873. Flip to `live` on the commit that cites the renderer read point, and drop `authorWarn` / `authorHint` in that same edit.", + "children": { + "formats": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `formats`, csv / xlsx only)", + "note": "The formats the export control offers; absent = both, `[]` = off. Planned with its container: no renderer draws the control." + }, + "includeHeaders": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `includeHeaders`, the list export member)", + "note": "Whether the download carries a header row. Planned with its container: no renderer draws the control." + }, + "fileNamePrefix": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `fileNamePrefix`, the list export member)", + "note": "The download file name prefix, in place of the report label. Planned with its container: no renderer draws the control." + } + } + }, "blocks": { "status": "live", "verifiedAt": "2026-09-27", diff --git a/packages/spec/liveness/state-counts/report.md b/packages/spec/liveness/state-counts/report.md index ab1a34f8c02..675a2283d98 100644 --- a/packages/spec/liveness/state-counts/report.md +++ b/packages/spec/liveness/state-counts/report.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `report` | 21 | 0 | 0 | 0 | 0 | 21 | +| `report` | 21 | 0 | 0 | 0 | 6 | 27 | diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts index 7259176ef32..7184c45be4b 100644 --- a/packages/spec/scripts/liveness/empty-state-registry.mts +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -149,6 +149,13 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ rationale: '[ADR-0085] Absence permits stage heuristics to infer the lifecycle field; `false` suppresses them. It selects how the renderer guesses, not who may read anything.', }, + { + file: 'packages/spec/src/ui/report.zod.ts', + property: 'dateRange', + semantics: 'scope', + rationale: + 'A report with no `dateRange` applies no date window, so its table shows every period, exactly as before the key existed. It selects which aggregated rows the table shows, not who may read them: the dataset query still runs under the base object\'s permissions and row-level security, so a missing window widens the period, never the access.', + }, { file: 'packages/spec/src/ui/page.zod.ts', property: 'columns', diff --git a/packages/spec/src/ui/list-view-export-options.ts b/packages/spec/src/ui/list-view-export-options.ts index b8cd66cef62..fb7c5934947 100644 --- a/packages/spec/src/ui/list-view-export-options.ts +++ b/packages/spec/src/ui/list-view-export-options.ts @@ -24,6 +24,13 @@ * addition: the object's own answer to a bare array (see * {@link LIST_VIEW_EXPORT_OPTIONS_ARRAY_FORM}). The list view's published JSON * Schema did not move with it. + * + * [#26548] A report's export block lives here too, at the end of the module: + * {@link ReportExportOptionsSchema}, three of these five members under the + * same key name, so a report and a list share one export vocabulary rather + * than two. It is a sibling, not a third carrier of the object above — a + * report table is not a record grid, and the members it leaves out are the + * record-stream ones. */ import { z } from 'zod'; @@ -137,3 +144,86 @@ export const ListViewExportOptionsSchema = closedObject(z.object(LIST_VIEW_EXPOR { prime: () => (exportOptionsUnknownKeyError as { prime?: () => void }).prime?.() }, ), }).strict()); + +// ── The report's export block (#26548) ────────────────────────────────────── + +/** + * The formats a REPORT table exports: the list's enum narrowed to `csv` and + * `xlsx` (ruling 6408567749 on #25873, letter B). A report's rows are + * aggregates, not records, so `'json'` — the list's record format — is not + * offered, and a report never had `'pdf'`. The refusal names the two report + * formats rather than replaying the list's `'pdf'` retirement, whose sentence + * ("the surviving formats are 'csv', 'xlsx' and 'json'", a view's + * `allowPrinting`) is true of a list and not of a report. Every other value + * keeps zod's own enum message, which already lists the two members. + */ +const ReportExportFormatSchema = ListViewExportFormatSchema.exclude(['json'], { + error: (issue) => { + if (issue.input === 'json') { + return "a report exports its table as 'csv' or 'xlsx'. 'json' is a list export's record format, " + + "and a report's rows are aggregates with no record shape to write. Use 'csv' or 'xlsx'."; + } + if (issue.input === 'pdf') { + return "a report exports its table as 'csv' or 'xlsx' — there is no PDF export of a report. " + + 'A printable document is a page that declares `print`, with its blocks in `regions`.'; + } + return undefined; + }, +}); + +/** + * The record-stream members of the list block that a report does not carry: + * a report's rows are already aggregated, and bounded by what it selects. + */ +const REPORT_EXPORT_RECORD_STREAM_MEMBER = + 'is a list export\'s record-stream member, and a report has none: its rows are already aggregated ' + + 'and bounded by what the report selects, and its file is written from the table as drawn. Delete it.'; + +const REPORT_EXPORT_OPTIONS_ARRAY_FORM = + 'Expected the report export options object `{ formats?, includeHeaders?, fileNamePrefix? }`, ' + + "received a bare array. Put the format list in `formats`: `{ formats: ['csv', 'xlsx'] }`."; + +/** + * Three of the list block's five members, and the list's key name — no second + * export vocabulary. `includeHeaders` is the list's member BY IDENTITY; + * `fileNamePrefix` is the list's member re-described, because the list's + * sentence names an object label and a view label a report has neither of. + * `formats` narrows the list's enum (see {@link ReportExportFormatSchema}). + */ +const REPORT_EXPORT_OPTIONS_SHAPE = { + formats: z.array(ReportExportFormatSchema).optional() + .describe("Formats the report's export control offers (default: ['csv', 'xlsx']); an empty list turns the control off"), + includeHeaders: LIST_VIEW_EXPORT_OPTIONS_SHAPE.includeHeaders, + fileNamePrefix: LIST_VIEW_EXPORT_OPTIONS_SHAPE.fileNamePrefix + .describe('Download file name prefix — replaces the report label in the generated file name'), +}; + +const reportExportOptionsUnknownKeyError = strictObjectError({ + surface: 'this report export options block', + history: 'Declared closed from its first day: an unrecognized key is refused, never dropped.', + guidance: { + maxRecords: `\`maxRecords\` ${REPORT_EXPORT_RECORD_STREAM_MEMBER}`, + streaming: `\`streaming\` ${REPORT_EXPORT_RECORD_STREAM_MEMBER}`, + }, +}, REPORT_EXPORT_OPTIONS_SHAPE); + +/** + * `ReportSchema.exportOptions` (#26548): what a report's export control + * offers. Who may use it is not declared here — the report TSDoc states the + * `ObjectPermission.allowExport` axis it is gated on. + * + * Deliberately NOT the list's union: there is no legacy bare-array spelling + * to lift on a report, and a union collapses its curated messages into one + * bare `Invalid input` (#5014). A bare array is answered by name instead. + * Closed exactly as {@link ListViewExportOptionsSchema} is, `prime` forwarded. + */ +export const ReportExportOptionsSchema = closedObject(z.object(REPORT_EXPORT_OPTIONS_SHAPE, { + error: Object.assign( + (issue: Parameters[0]) => ( + issue.code === 'invalid_type' && Array.isArray(issue.input) + ? REPORT_EXPORT_OPTIONS_ARRAY_FORM + : reportExportOptionsUnknownKeyError(issue) + ), + { prime: () => (reportExportOptionsUnknownKeyError as { prime?: () => void }).prime?.() }, + ), +}).strict()); diff --git a/packages/spec/src/ui/report-date-range-export.test.ts b/packages/spec/src/ui/report-date-range-export.test.ts new file mode 100644 index 00000000000..befd637ea2f --- /dev/null +++ b/packages/spec/src/ui/report-date-range-export.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ReportSchema, JoinedReportBlockSchema, defineReport } from './report.zod'; +import { DashboardSchema, DATE_RANGE_DEFAULT_RANGES } from './dashboard.zod'; +import { ListViewExportOptionsSchema } from './list-view-export-options'; +import { strictObjectDeclarations } from '../shared/strict-object'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +/** + * #26548 — a report's period (`dateRange`) and export (`exportOptions`), + * ruling 6408567749 on #25873 (letter B). Both keys are optional and + * top-level; a report declaring neither parses exactly as before. + */ + +const VALID = { + name: 'pipeline', label: 'Pipeline', type: 'summary', + dataset: 'sales', rows: ['stage'], values: ['revenue'], +} as const; + +const JOINED = { + name: 'funnel', label: 'Funnel', type: 'joined', + blocks: [{ name: 'won', dataset: 'sales', rows: ['stage'], values: ['revenue'] }], +} as const; + +const issuesOf = (input: unknown) => { + const r = ReportSchema.safeParse(input); + expect(r.success, 'expected a refusal').toBe(false); + return r.error!.issues; +}; + +const unknownKeyIssue = (input: unknown, key: string) => { + const issue = issuesOf(input).find((i) => i.code === 'unrecognized_keys'); + expect(issue, `\`${key}\` must be refused as an unknown key`).toBeDefined(); + expect((issue as { keys?: readonly string[] }).keys).toEqual([key]); + return issue!; +}; + +describe('a report without either key is untouched', () => { + it('parses byte-identically, and the output carries neither key', () => { + const parsed = ReportSchema.parse(VALID); + expect(parsed).not.toHaveProperty('dateRange'); + expect(parsed).not.toHaveProperty('exportOptions'); + expect(parsed).toEqual({ ...VALID, drilldown: true }); + }); + + it('a joined report without either key parses as before', () => { + expect(ReportSchema.safeParse(JOINED).success).toBe(true); + }); +}); + +describe('ReportSchema.dateRange', () => { + it('`{}` parses to the dashboard defaults', () => { + const parsed = ReportSchema.parse({ ...VALID, dateRange: {} }); + expect(parsed.dateRange).toEqual({ defaultRange: 'this_month', allowCustomRange: true }); + }); + + it('carries a named date dimension through', () => { + const parsed = ReportSchema.parse({ + ...VALID, + dateRange: { dimension: 'close_date', defaultRange: 'this_quarter', allowCustomRange: false }, + }); + expect(parsed.dateRange).toEqual({ dimension: 'close_date', defaultRange: 'this_quarter', allowCustomRange: false }); + }); + + it.each(DATE_RANGE_DEFAULT_RANGES.map((r) => [r]))('accepts every dashboard default range — `%s`', (defaultRange) => { + const r = ReportSchema.safeParse({ ...VALID, dateRange: { defaultRange } }); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); + + it('refuses a preset the table does not hold, and the message names the presets', () => { + const issue = issuesOf({ ...VALID, dateRange: { defaultRange: 'last_6_months' } })[0]!; + expect(issue.path).toEqual(['dateRange', 'defaultRange']); + expect(issue.code).toBe('invalid_value'); + for (const preset of ['this_quarter', 'last_30_days', 'custom']) expect(issue.message).toContain(preset); + }); + + it.each([ + ['dateField', 'dimension', 'close_date'], + ['field', 'dimension', 'close_date'], + ['timeDimension', 'dimension', 'close_date'], + ['preset', 'defaultRange', 'this_year'], + ['range', 'defaultRange', 'this_year'], + ['allowCustom', 'allowCustomRange', false], + ] as const)('`%s` earns its rename hint onto `%s`', (key, target, value) => { + const issue = unknownKeyIssue({ ...VALID, dateRange: { [key]: value } }, key); + expect(issue.path).toEqual(['dateRange']); + expect(issue.message).toContain(`Did you mean \`${key}\` → \`${target}\`?`); + // Taking the advice parses. + expect(ReportSchema.safeParse({ ...VALID, dateRange: { [target]: value } }).success).toBe(true); + }); + + it('refuses an unknown member', () => { + unknownKeyIssue({ ...VALID, dateRange: { granularity: 'month' } }, 'granularity'); + }); + + it.each([['dateFilter'], ['timeRange']])('the dashboard top-level spelling `%s` is renamed onto `dateRange`', (key) => { + const issue = unknownKeyIssue({ ...VALID, [key]: {} }, key); + expect(issue.message).toContain(`Did you mean \`${key}\` → \`dateRange\`?`); + }); + + it('is refused on a joined container at its own path, `{}` included, with no pointer onto blocks', () => { + const issues = issuesOf({ ...JOINED, dateRange: {} }); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['dateRange']); + expect(issues[0]!.message).toMatch(/^a `joined` report takes no `dateRange`/); + expect(issues[0]!.message).not.toContain('blocks[]'); + }); + + it('a block declares no `dateRange` — refused with its guidance, at the block', () => { + const issue = unknownKeyIssue({ ...JOINED, blocks: [{ ...JOINED.blocks[0], dateRange: {} }] }, 'dateRange'); + expect(issue.path).toEqual(['blocks', 0]); + expect(issue.message).toContain('`dateRange` is a key of a non-joined report, not of a block'); + expect(issue.message).not.toContain('Did you mean'); + }); + + /** + * "Verbatim" is asserted, not stated: the two members a report shares with + * the dashboard are compared at the live declarations — enum, default, + * describe — and the dashboard's alias hints for them are carried unchanged. + */ + it('shares the dashboard declaration verbatim — one preset table, one set of defaults and hints', () => { + ReportSchema.safeParse(VALID); + DashboardSchema.safeParse({ name: 'd', label: 'D', widgets: [] }); + const declarationOf = (surface: string) => { + const found = strictObjectDeclarations().filter((d) => d.options.surface === surface); + expect(found, `exactly one declaration answers to "${surface}"`).toHaveLength(1); + return found[0]!; + }; + const report = declarationOf('this report date range'); + const board = declarationOf('this dashboard date range'); + + for (const member of ['defaultRange', 'allowCustomRange'] as const) { + const ours = report.shape[member] as z.ZodType; + const theirs = board.shape[member] as z.ZodType; + expect(ours.description, member).toBe(theirs.description); + expect(z.toJSONSchema(ours), member).toEqual(z.toJSONSchema(theirs)); + } + const boardHints = Object.entries(board.options.aliases ?? {}).filter(([, t]) => t === 'defaultRange' || t === 'allowCustomRange'); + expect(boardHints.length).toBeGreaterThan(0); + for (const [key, target] of boardHints) expect(report.options.aliases?.[key], key).toBe(target); + // The dashboard's raw-field `field` key is a report's `dimension`; every + // spelling the dashboard routes onto `field` routes onto `dimension` here. + for (const [key, target] of Object.entries(board.options.aliases ?? {})) { + if (target === 'field') expect(report.options.aliases?.[key], key).toBe('dimension'); + } + expect(report.options.aliases?.field).toBe('dimension'); + }); +}); + +describe('ReportSchema.exportOptions', () => { + it.each([ + [{ formats: ['csv', 'xlsx'] }], + [{ formats: [] }], + [{ includeHeaders: false, fileNamePrefix: 'pipeline' }], + [{}], + ])('accepts %j', (exportOptions) => { + const parsed = ReportSchema.parse({ ...VALID, exportOptions }); + expect(parsed.exportOptions).toEqual(exportOptions); + }); + + it('refuses `json` with a message naming the two report formats', () => { + const issue = issuesOf({ ...VALID, exportOptions: { formats: ['json'] } })[0]!; + expect(issue.path).toEqual(['exportOptions', 'formats', 0]); + expect(issue.message).toMatch(/^a report exports its table as 'csv' or 'xlsx'\. 'json' is a list export's record format/); + }); + + it('refuses `pdf` with the report-side answer, not the list retirement sentence', () => { + const issue = issuesOf({ ...VALID, exportOptions: { formats: ['pdf'] } })[0]!; + expect(issue.path).toEqual(['exportOptions', 'formats', 0]); + expect(issue.message).toMatch(/^a report exports its table as 'csv' or 'xlsx' — there is no PDF export of a report/); + expect(issue.message).not.toContain('view.exportOptions'); + }); + + it('refuses any other value with the enum message listing csv and xlsx only', () => { + const issue = issuesOf({ ...VALID, exportOptions: { formats: ['docx'] } })[0]!; + expect(issue.code).toBe('invalid_value'); + expect(issue.message).toContain('"csv"'); + expect(issue.message).toContain('"xlsx"'); + expect(issue.message).not.toContain('"json"'); + }); + + it.each([['maxRecords', 500], ['streaming', false]] as const)( + 'refuses the list record-stream member `%s` as unknown, with its guidance', + (key, value) => { + const issue = unknownKeyIssue({ ...VALID, exportOptions: { [key]: value } }, key); + expect(issue.path).toEqual(['exportOptions']); + expect(issue.message).toContain(`\`${key}\` is a list export's record-stream member, and a report has none`); + }, + ); + + it('answers a bare format array by name', () => { + const issue = issuesOf({ ...VALID, exportOptions: ['csv'] })[0]!; + expect(issue.path).toEqual(['exportOptions']); + expect(issue.message).toMatch(/^Expected the report export options object/); + }); + + it('is refused on a joined container at its own path', () => { + const issues = issuesOf({ ...JOINED, exportOptions: { formats: ['csv'] } }); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['exportOptions']); + expect(issues[0]!.message).toMatch(/^a `joined` report offers no export/); + }); + + it('a block declares no `exportOptions` — refused with its guidance', () => { + const r = JoinedReportBlockSchema.safeParse({ ...JOINED.blocks[0], exportOptions: {} }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.message).toContain('`exportOptions` is a key of a non-joined report, not of a block'); + }); + + it('shares the list block\'s members — `includeHeaders` by identity, the list schema unchanged', () => { + const report = ReportSchema.parse({ ...VALID, exportOptions: { includeHeaders: true } }); + expect(report.exportOptions).toEqual({ includeHeaders: true }); + // The list block still carries all five members and its own formats. + expect(ListViewExportOptionsSchema.safeParse({ formats: ['json'], maxRecords: 5, streaming: false }).success).toBe(true); + }); +}); + +describe('guidance on report keys that are not report metadata', () => { + it('`allowExport` names the permission-set axis and the report key', () => { + const issue = unknownKeyIssue({ ...VALID, allowExport: true }, 'allowExport'); + expect(issue.message).toContain('`ObjectPermission.allowExport`'); + expect(issue.message).toContain('`exportOptions: { formats: [] }`'); + }); + + it.each([['subscription'], ['schedule']])('`%s` is answered: no scheduled delivery of a report', (key) => { + const issue = unknownKeyIssue({ ...VALID, [key]: { cron: '0 8 * * 1' } }, key); + expect(issue.message).toContain('a report declares no subscription or scheduled delivery'); + }); +}); + +describe('every door reads the same schema', () => { + it('the authoring factory and the save door refuse a joined date range alike', () => { + expect(() => defineReport({ ...JOINED, dateRange: {} } as never)).toThrow(/takes no `dateRange`/); + const registry = getMetadataTypeSchema('report'); + expect(registry).toBeDefined(); + const r = registry!.safeParse({ ...JOINED, dateRange: {} }); + expect(r.success).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/report.form.ts b/packages/spec/src/ui/report.form.ts index 8bc1038e361..d29018a2cd4 100644 --- a/packages/spec/src/ui/report.form.ts +++ b/packages/spec/src/ui/report.form.ts @@ -108,6 +108,21 @@ export const reportForm = defineForm({ { field: 'chart', type: 'composite', visibleWhen: "data.type != 'joined'", helpText: 'Chart config (type, legend, colors)' }, ], }, + { + // #26548 — the report page's period and its table download. A `joined` + // report refuses both keys (its blocks may bind different datasets, and + // it draws one table per block), so the section is offered only where + // they parse. + label: 'Period & export', + description: 'The default period of the report and the formats its table can be downloaded in.', + collapsible: true, + collapsed: true, + visibleWhen: "data.type != 'joined'", + fields: [ + { field: 'dateRange', type: 'composite', helpText: 'Default period and custom-range switch. The dimension must be a date dimension of the dataset; leave it empty when the dataset has exactly one.' }, + { field: 'exportOptions', type: 'composite', helpText: 'Download formats (csv, xlsx) and file name. Who may download is the export permission on the dataset object.' }, + ], + }, // An "Advanced" section offering `aria` + `performance` used to sit here. // #3496 pruned both keys from ReportSchema (dead in both layers) but left // this form untouched, so the section rendered two controls whose values diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 24622fcda3b..623b98a2aa5 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -5,6 +5,8 @@ import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { ChartConfigSchema } from './chart.zod'; +import { DATE_RANGE_DEFAULT_RANGES } from './dashboard.zod'; +import { ReportExportOptionsSchema } from './list-view-export-options'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema } from './i18n.zod'; @@ -181,6 +183,46 @@ const JOINED_CONTAINER_CHART_REFUSED = + 'non-joined report of its own with that `chart`. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +/** + * The refusals a `joined` report's `dateRange` and `exportOptions` carry, on + * the container (ruling 6408567749 on #25873, letter B; #26548). Both are + * keys of ONE table over ONE dataset, and a joined report is neither: its + * blocks may bind different datasets, so a single `dateRange.dimension` + * cannot name a date column in every one of them, and it draws one table per + * block where a report exports the table it draws. A block declares neither + * key — its `guidance` entries below say so — so, like the container `chart`, + * the refusal carries no pointer onto `blocks[]`. A per-block or + * conformed-dimension date range (ADR-0021 Q3) and a multi-table export are + * later widenings, not this shape. + */ +const JOINED_CONTAINER_DATE_RANGE_REFUSED = + 'a `joined` report takes no `dateRange`: its blocks may bind different datasets, so one ' + + '`dimension` cannot name a date column in every block, and a block has no `dateRange` of its ' + + 'own. Delete `dateRange`; to window one of these slices by period, give it a non-joined report ' + + 'of its own with that `dateRange`.'; + +const JOINED_CONTAINER_EXPORT_OPTIONS_REFUSED = + 'a `joined` report offers no export: it draws one table per block, and a report exports the one ' + + 'table it draws. Delete `exportOptions`; to export one of these slices, give it a non-joined ' + + 'report of its own.'; + +/** + * The answer to `subscription` / `schedule` on a report (#26548). A scheduled + * delivery of a report was weighed beside its date range and export and not + * adopted (ruling 6408567749 on #25873: letter C waits for a customer who asks + * for it), so nothing in the platform delivers a report on a timetable — and a + * key that parsed would promise exactly that. + */ +const REPORT_SUBSCRIPTION_NOT_OFFERED = + 'a report declares no subscription or scheduled delivery: the platform does not send reports on a ' + + 'schedule, so there is no key for it. Delete the key. The period a report covers is its ' + + '`dateRange`, and the download of its table is `exportOptions`.'; + +/** A block's answer to `dateRange` / `exportOptions`: the container's keys, refused there too. */ +const BLOCK_NON_JOINED_REPORT_KEY = (key: 'dateRange' | 'exportOptions'): string => + `\`${key}\` is a key of a non-joined report, not of a block, and a \`joined\` container refuses it ` + + 'as well. Give the slice a non-joined report of its own and declare it there.'; + /** * The refusal a `joined` report's block with no `dataset` earns, at * `blocks[i].dataset` (#21702; ADR-0021 single-form, ADR-0049 enforce-or-remove, @@ -286,6 +328,12 @@ export const JoinedReportBlockSchema = lazySchema(() => strictObject({ // closed shape, so the prescription is what an author who keeps writing it // is answered with (build-schemas check (c) proof 4 reads it from here). chart: JOINED_BLOCK_CHART_RETIRED, + // #26548 — two keys of a NON-joined report: one dataset's date window and + // the export of the one table it draws. A joined container refuses both + // (see `JOINED_CONTAINER_DATE_RANGE_REFUSED`), so there is no layer above + // to point at either. + dateRange: BLOCK_NON_JOINED_REPORT_KEY('dateRange'), + exportOptions: BLOCK_NON_JOINED_REPORT_KEY('exportOptions'), }, }, { /** Stable id for the block (used as react key, telemetry, deeplinks). */ @@ -401,8 +449,23 @@ export const ReportSchema = lazySchema(() => strictObject({ sort: 'order', orderBy: 'order', sortBy: 'order', + // #26548 — the dashboard's own top-level spellings for its date range + // (`DashboardSchema`), aimed at the same key here. A block declares no + // `dateRange`, so these have no block twin to keep in parity with. + dateFilter: 'dateRange', + timeRange: 'dateRange', }, guidance: { + // #26548 — the export PERMISSION is not report metadata. Writing it here + // reads as "allow exporting this report", which is the question the + // permission-set axis answers for the dataset's base object; edit distance + // would offer nothing, and silence would leave the author guessing. + allowExport: + '`allowExport` is a permission, not a report key: it is `ObjectPermission.allowExport` on a permission set, granted per object. A report offers its export control to a viewer whose permissions allow export of the report\'s dataset base object, and declares only what is exported, in `exportOptions` (`formats`, `includeHeaders`, `fileNamePrefix`). To turn the control off for everyone, write `exportOptions: { formats: [] }`.', + // #26548 — a subscription / scheduled delivery was not adopted with the + // date range and export (ruling 6408567749 on #25873 deferred letter C). + subscription: REPORT_SUBSCRIPTION_NOT_OFFERED, + schedule: REPORT_SUBSCRIPTION_NOT_OFFERED, // #5022 — the reverse half of a two-way disambiguation. The forward half // lives on `ChartDrillDownSchema` in `ui/chart.zod.ts`, which tells an // author who writes `drilldown` on a CHART that they want the report's @@ -499,6 +562,95 @@ export const ReportSchema = lazySchema(() => strictObject({ */ chart: ReportChartSchema.optional().describe('Embedded chart configuration (refused on a joined report, which draws tables only)'), + /** + * The report's period: a date window over ONE dataset date dimension, with + * a default preset and an optional custom pick (ruling 6408567749 on + * #25873, letter B; #26548). + * + * `defaultRange` and `allowCustomRange` are the dashboard's + * (`DashboardSchema.dateRange`) verbatim: the same preset table + * (`DATE_RANGE_DEFAULT_RANGES` — every `DATE_RANGE_PRESETS` member plus the + * `custom` "open the picker, no window" sentinel), the same defaults and the + * same rename hints. `report-date-range-export.test.ts` holds the two declarations + * equal, so the vocabulary cannot drift into a second preset table. + * + * `dimension` is where the two differ, on purpose. The dashboard's `field` + * names a raw object FIELD; a report names a dataset DIMENSION (ADR-0021 — + * a report selects by name, and a dimension carries its own relationship + * path, so `account.created_at` needs no new grammar). The dimension must + * declare `type: 'date'`: the analytics compiler types any other dimension + * as a string axis, not a time one. Omitted, the dataset's ONE `type: 'date'` + * dimension is used; zero or several is an authoring error naming what it + * found — the rule the dashboard executor applies to an omitted + * `compareTo.dimension`. `ReportSchema` parses without the dataset, so this + * shape checks only itself; the dataset-aware half is the `@objectstack/lint` + * rule `validate-report-date-range.ts` (`os validate`, `os build`, and the + * report publish door). + * + * Lowering (the renderer's contract): the active window rides the dataset + * query as `selection.timeDimensions: [{ dimension, dateRange }]` with NO + * `granularity` — a filter window, not a bucket, ANDed with `runtimeFilter` + * and the dataset's own filter. A preset goes on the wire by name; a custom + * pick as `[start, end]`. Bucketing stays on the dimension's + * `dateGranularity` and the report's `rows` / `columns`. + * + * Refused on a `joined` report — see {@link JOINED_CONTAINER_DATE_RANGE_REFUSED}. + * + * Absent = no window, every period — exactly as a report has always + * rendered. The window selects which rows the table shows and grants + * nothing: what a viewer may read stays with the dataset object's access. + */ + dateRange: strictObject({ + surface: 'this report date range', + history: 'Declared closed from its first day: an unrecognized key is refused, never dropped.', + aliases: { + // The dashboard's own rename hints for these two members, verbatim. + preset: 'defaultRange', + range: 'defaultRange', + default: 'defaultRange', + allowCustom: 'allowCustomRange', + custom: 'allowCustomRange', + // "Which date column": the dashboard's `field` and its two spellings, + // and the wire's `timeDimensions[].dimension` — the same words the + // dashboard's `compareTo` routes onto its own `dimension`. + field: 'dimension', + dateField: 'dimension', + fieldName: 'dimension', + timeDimension: 'dimension', + }, + }, { + dimension: z.string().optional() + .describe('Dataset dimension (declared `type: \'date\'`) the window applies to; omitted = the dataset\'s one date dimension'), + defaultRange: z.enum(DATE_RANGE_DEFAULT_RANGES).default('this_month').describe('Default date range preset'), + allowCustomRange: z.boolean().default(true).describe('Allow users to pick a custom date range'), + }).optional().describe('Report date range: a window over one dataset date dimension (refused on a joined report)'), + + /** + * What the report's export control offers (ruling 6408567749 on #25873, + * letter B; #26548). The members are the list export block's + * (`view.exportOptions`), narrowed to what a report table needs — see + * {@link ReportExportOptionsSchema} for what is and is not carried over. + * + * Default ON: absent, a report offers `['csv', 'xlsx']`, as a list offers + * its default formats unasked; `formats: []` turns the control off. + * + * Who may export is NOT declared here — there is no report permission key. + * The control is offered to a viewer whose effective API operations on the + * dataset's base object include `export`: the `ObjectPermission.allowExport` + * axis, through the same predicate the list toolbar uses. + * + * What is exported (the renderer's contract): the report table as drawn, + * within the active `dateRange` and `runtimeFilter` — the selected rows × + * values, the matrix pivot and the server totals — with headers from the + * dimension and measure labels. The file is named for the report label and + * the period; `fileNamePrefix` replaces the label. + * + * Refused on a `joined` report — see {@link JOINED_CONTAINER_EXPORT_OPTIONS_REFUSED}. + */ + exportOptions: ReportExportOptionsSchema.optional().describe( + 'Report export control: `{ formats?, includeHeaders?, fileNamePrefix? }`, csv / xlsx, on by default; who may export is the base object\'s export permission (refused on a joined report)', + ), + /** * Joined report blocks — only meaningful when `type: 'joined'`. * @@ -585,6 +737,15 @@ export const ReportSchema = lazySchema(() => strictObject({ if (r.chart !== undefined) { ctx.addIssue({ code: 'custom', message: JOINED_CONTAINER_CHART_REFUSED, path: ['chart'] }); } + // #26548 — the same arm, the same no-pointer style: one dataset's date + // window and the export of one drawn table, neither of which a joined + // container has. Any present value is refused, `{}` included. + if (r.dateRange !== undefined) { + ctx.addIssue({ code: 'custom', message: JOINED_CONTAINER_DATE_RANGE_REFUSED, path: ['dateRange'] }); + } + if (r.exportOptions !== undefined) { + ctx.addIssue({ code: 'custom', message: JOINED_CONTAINER_EXPORT_OPTIONS_REFUSED, path: ['exportOptions'] }); + } } else { checkReportOrder(r, ctx); } diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 057f19aa5ff..216d40c51f3 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -398,10 +398,9 @@ export const PipelineCoverageReport = defineReport({ columns: ['close_date'], // across axis (ADR-0021 D2) — matrix pivots rows × columns values: ['amount_sum'], // measures placed in the cells runtimeFilter: { stage: { $ne: 'closed_lost' } }, - // Optional ordering, most significant key first. A selected DATE dimension is - // already chronological by default — declare `order` only to change that, or - // to sort by a measure / a non-date dimension. - order: [{ by: 'amount_sum', direction: 'desc' }], + // Period: ONE `type: 'date'` dataset dimension, not a dashboard-style raw `field`. + dateRange: { dimension: 'close_date', defaultRange: 'this_year' }, + order: [{ by: 'amount_sum', direction: 'desc' }], // by measure; dates default to chronological chart: { type: 'bar', xAxis: 'forecast_category', yAxis: 'amount_sum' }, }); ```