From b7cd0cf5bfba632f94a93f7355d8315f882e26f2 Mon Sep 17 00:00:00 2001 From: akira69 Date: Tue, 10 Mar 2026 00:40:32 -0500 Subject: [PATCH 1/6] feat(settings): formula extra fields with JSON logic authoring and API integration --- JSON_LOGIC_SPIKE.md | 177 ++ client/package-lock.json | 232 +++ client/package.json | 10 +- client/public/locales/en/common.json | 165 +- client/src/index.tsx | 22 + client/src/pages/filaments/list.tsx | 81 +- client/src/pages/filaments/show.tsx | 20 +- client/src/pages/help/index.tsx | 473 ++++- .../printing/spoolQrCodePrintingDialog.tsx | 43 +- .../pages/settings/complexFieldsSettings.tsx | 1834 +++++++++++++++++ .../pages/settings/extraFieldsSettings.tsx | 109 +- client/src/pages/settings/index.tsx | 1 - client/src/pages/spools/list.tsx | 79 +- client/src/pages/spools/show.tsx | 20 +- client/src/pages/vendors/list.tsx | 81 +- client/src/pages/vendors/show.tsx | 20 +- client/src/utils/formulaFields.ts | 536 +++++ client/src/utils/queryFields.ts | 121 ++ scripts/json_logic_parity.py | 304 +++ scripts/pr-preflight.sh | 174 ++ spoolman/api/v1/field.py | 105 + spoolman/api/v1/filament.py | 70 +- spoolman/api/v1/models.py | 23 +- spoolman/api/v1/spool.py | 65 +- spoolman/api/v1/vendor.py | 65 +- spoolman/derived_fields.py | 613 ++++++ spoolman/settings.py | 4 + .../fields/json_logic_parity_fixtures.json | 142 ++ .../tests/fields/test_derived.py | 60 + .../tests/fields/test_derived_api.py | 98 + 30 files changed, 5677 insertions(+), 70 deletions(-) create mode 100644 JSON_LOGIC_SPIKE.md create mode 100644 client/src/pages/settings/complexFieldsSettings.tsx create mode 100644 client/src/utils/formulaFields.ts create mode 100644 scripts/json_logic_parity.py create mode 100755 scripts/pr-preflight.sh create mode 100644 spoolman/derived_fields.py create mode 100644 tests_integration/tests/fields/json_logic_parity_fixtures.json create mode 100644 tests_integration/tests/fields/test_derived.py create mode 100644 tests_integration/tests/fields/test_derived_api.py diff --git a/JSON_LOGIC_SPIKE.md b/JSON_LOGIC_SPIKE.md new file mode 100644 index 000000000..44af8cae2 --- /dev/null +++ b/JSON_LOGIC_SPIKE.md @@ -0,0 +1,177 @@ +# JSON Logic Spike (Clean-Cut) for Formula Extra Fields + +## Status +- Owner: TBD +- Branch: `feat/complex-fields-framework` (PR #874 context) +- Date: 2026-03-05 +- Decision type: Spike RFC (implementation-first, no legacy compatibility) + +## Background +Current formula fields use a custom expression syntax/evaluator with limited typing and helper coverage. +We want richer boolean/date/text logic and safer execution semantics without continuing ad-hoc parser growth. + +Relevant requests: +- [#795](https://github.com/Donkie/Spoolman/issues/795) Labels: Date formatting +- [#853](https://github.com/Donkie/Spoolman/issues/853) Sort by hue +- [#870](https://github.com/Donkie/Spoolman/issues/870) Show empty spool weight column +- [#783](https://github.com/Donkie/Spoolman/issues/783) Extra action buttons per spool (partially related) + +## Goals +- Replace the formula expression engine with JSON Logic for formula extra fields. +- Support result types: `number`, `text`, `boolean`, `date`, `datetime`, `time`. +- Enable richer operators: logical, comparison, conditional, string, and date helpers. +- Enforce frontend/backend evaluation parity for previews and rendered values. +- Keep execution safe and deterministic (no unrestricted eval). + +## Non-Goals +- Backward compatibility with old formula syntax. +- Automatic migration of existing formula definitions. +- Replacing complex extra fields that add custom UI/actions/workflows. +- Server-side indexed filtering/sorting on computed values in this spike. + +## Decision (Proposed) +- Use JSON Logic as the canonical formula representation. +- Store formulas as JSON AST only. +- Remove legacy expression parser/evaluator once spike implementation is accepted. + +## Runtime Candidate Snapshot (2026-03-05) +| Candidate | Role | License | Activity signal | Notes | +| --- | --- | --- | --- | --- | +| `json-logic/json-logic-engine` | Frontend runtime | MIT | pushed 2026-01-21 | Active JS implementation with custom operator support. | +| `jwadhams/json-logic-js` | Frontend/runtime reference | MIT | pushed 2024-07-09 | Canonical older implementation, broader adoption but slower recent change pace. | +| `nadirizr/json-logic-py` | Backend runtime | MIT | pushed 2023-12-19 | Usable baseline, but older activity and parity risk with modern JS engines. | +| `llnl/jsonlogic` | Python tooling (non-evaluator) | MIT | pushed 2026-03-05 | Provides JSON Logic expression generation helpers, not a direct evaluator runtime. | +| `cloud-custodian/cel-python` | Alternative (non-JSON Logic) | Apache-2.0 | pushed 2026-02-17 | Strong typed alternative if JSON Logic parity fails. | + +Proposed spike baseline: +- Frontend: `json-logic-engine` +- Backend: start with `nadirizr/json-logic-py` for evaluator parity harness; reassess additional evaluator candidates after fixture runs. + +## Proposed Architecture +### Data model +- `DerivedFieldDefinition` stores: +- `result_type` +- `expression_json` (JSON object, required) +- Keep current surfaces/column toggle behavior unchanged. + +### Backend +- Add a JSON Logic evaluator wrapper in Python. +- Provide a strict operator allowlist. +- Add custom operators for Spoolman domain helpers: +- `today`, `date_only`, `time_only`, `days_between`, `hours_between`, `hue_from_hex`, `coalesce` +- Validate AST at save-time with: +- operator allowlist checks +- reference format checks +- result type compatibility checks +- Preview endpoint accepts JSON AST and sample values. + +### Frontend +- Formula editor becomes JSON Logic editor: +- raw JSON textarea in spike phase +- clickable chips for references/operators +- preview panel unchanged in behavior +- Type-aware helper palette by selected `result_type`. +- Keep current list/show/template surface controls. + +## Operator/Helper Catalog (Initial) +### Logical and conditional +- `and`, `or`, `!`, `if`, `??` (or `coalesce`) + +### Comparison +- `==`, `!=`, `<`, `<=`, `>`, `>=` + +### Numeric +- `+`, `-`, `*`, `/`, `%`, `abs`, `min`, `max`, `round`, `floor`, `ceil` + +### Text +- `cat`, `substr`, `lower`, `upper`, `trim`, `length`, `replace` + +### Date and time +- `today`, `year`, `month`, `day`, `hour`, `minute`, `second`, `timestamp` +- `date_only`, `time_only`, `days_between`, `hours_between` + +## Result Type Rules (Draft) +- `number`: numeric expressions only. +- `text`: string output or explicit stringify. +- `boolean`: logical/comparison output. +- `date`: ISO `YYYY-MM-DD`. +- `datetime`: ISO datetime string in UTC. +- `time`: ISO `HH:MM:SS`. + +Validation should fail early when inferred output does not match `result_type`. + +## Scope Boundaries vs Complex Extra Fields +JSON Logic formula fields can cover: +- computed columns +- status flags +- derived display values + +Complex extra fields remain for: +- UI actions/buttons/workflows +- module-defined interactions beyond scalar value computation + +## Implementation Plan +1. Backend foundation +- Add evaluator wrapper and allowlisted custom ops. +- Add AST validation and type checks. +- Update preview endpoint to JSON AST payload. + +2. Frontend foundation +- Replace expression editor with JSON AST editor UI. +- Add reference/operator chip insertion. +- Keep preview UX and surface controls. + +3. Integration +- Replace runtime formula evaluation in list/show/template render paths. +- Remove old formula parser/evaluator modules. + +4. Tests +- Backend unit tests for ops, validation, and type enforcement. +- Frontend tests for insertion and preview payload shape. +- Parity tests using shared fixtures for FE and BE outputs. + +## Risks +- JSON authoring UX can be heavy without a visual builder. +- FE/BE library semantic differences must be normalized. +- Date/time coercion edge cases can be surprising if not tightly specified. + +## Immediate Next Step Checklist (Phase 0, 1-2 days) +- Build a 20-case parity fixture set (`tests_integration` + frontend fixture file): +- arithmetic, boolean logic, null/coalesce, string ops, date helpers, hue helper, invalid syntax, invalid type. +- Run fixture set against two backend candidates and one frontend candidate. +- Record mismatches with exact operator semantics and type coercion behavior. +- Select backend runtime and lock the operator subset for v1. +- Freeze UTC/date behavior in writing (`today` allowed, `now` deferred). +- Finalize API contract for preview/save payload (`expression_json` only). + +## Phase 0 Snapshot (2026-03-05) +- Fixture set created: `tests_integration/tests/fields/json_logic_parity_fixtures.json` (20 cases). +- Runner created: `scripts/json_logic_parity.py`. +- Backend execution baseline: +- Engine: `json-logic-py` (sourced directly from GitHub due blocked PyPI access in this environment) +- Result: **20/20 pass** +- Meaning: operator set and custom helper wiring in the harness are viable for spike phase. + +## Phase 1 Snapshot (2026-03-05) +- Backend API accepts `expression_json` for preview/save with allowlisted JSON Logic operators and custom helpers. +- Frontend formula runtime evaluates `expression_json` when present, with legacy string expressions as fallback. +- Settings dependency checks now resolve custom-field references from both legacy expressions and JSON Logic `var` nodes. +- Formula editor accepts an optional `Expression JSON (JSON Logic)` payload for preview/save during transition. + +## Spike Exit Criteria +- At least 15 golden fixture expressions pass identically in FE and BE. +- Save-time validation blocks invalid operators/references/types. +- Preview and runtime rendering are consistent for all result types. +- Old expression engine fully removable without hidden dependencies. + +## Open Questions +- Which Python JSON Logic library will be used, and does it support needed custom ops cleanly? +- Should `now()` be excluded initially to avoid time-volatile values in displayed columns? +- Do we allow nested object outputs at all, or scalar-only forever? +- Should date/time helpers always be UTC-only in v1? + +## Deliverables +- `JSON_LOGIC_SPIKE.md` (this RFC) +- Prototype backend evaluator + validation +- Prototype frontend JSON editor + preview +- Test report with parity matrix and go/no-go recommendation diff --git a/client/package-lock.json b/client/package-lock.json index b19647a12..976446c60 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.23.1", "dependencies": { "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@codemirror/lang-json": "^6.0.2", "@loadable/component": "^5.16.7", "@refinedev/antd": "^6.0.3", "@refinedev/core": "^5.0.7", @@ -17,6 +18,7 @@ "@refinedev/simple-rest": "^6.0.1", "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", + "@uiw/react-codemirror": "^4.25.7", "@yudiel/react-qr-scanner": "^2.5.0", "axios": "^1.13.2", "dayjs": "^1.11.10", @@ -2020,6 +2022,109 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", + "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.39.16", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", + "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2948,6 +3053,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@loadable/component": { "version": "5.16.7", "resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.16.7.tgz", @@ -2969,6 +3109,12 @@ "react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4912,6 +5058,59 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.7", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.7.tgz", + "integrity": "sha512-tPV/AGjF4yM22D5mnyH7EuYBkWO05wF5Y4x3lmQJo6LuHmhjh0RQsVDjqeIgNOkXT3UO9OdkL4dzxw465/JZVg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.7", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.7.tgz", + "integrity": "sha512-s/EbEe0dFANWEgfLbfdIrrOGv0R7M1XhkKG3ShroBeH6uP9pVNQy81YHOLRCSVcytTp9zAWRNfXR/+XxZTvV7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.7", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@umijs/route-utils": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@umijs/route-utils/-/route-utils-4.0.3.tgz", @@ -6024,6 +6223,21 @@ "node": ">=0.10.0" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6232,6 +6446,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -13598,6 +13818,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-object": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", @@ -14599,6 +14825,12 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/warn-once": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", diff --git a/client/package.json b/client/package.json index 1603ff246..8d25ead0f 100644 --- a/client/package.json +++ b/client/package.json @@ -8,6 +8,7 @@ "type": "module", "dependencies": { "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@codemirror/lang-json": "^6.0.2", "@loadable/component": "^5.16.7", "@refinedev/antd": "^6.0.3", "@refinedev/core": "^5.0.7", @@ -16,6 +17,7 @@ "@refinedev/simple-rest": "^6.0.1", "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", + "@uiw/react-codemirror": "^4.25.7", "@yudiel/react-qr-scanner": "^2.5.0", "axios": "^1.13.2", "dayjs": "^1.11.10", @@ -39,19 +41,19 @@ "@refinedev/cli": "^2.16.50", "@types/loadable__component": "^5.13.10", "@types/node": "^25.0.3", - "@types/react-dom": "^19.2.3", "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", - "eslint-plugin-react": "^7.37.5", - "eslint": "^9.39.2", "globals": "^17.0.0", "prettier": "3.7.4", - "typescript-eslint": "^8.52.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.52.0", "vite": "^7.3.0", "vite-plugin-mkcert": "^1.17.9", "vite-plugin-pwa": "^1.2.0" diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..294ec7752 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -44,6 +44,7 @@ "editError": "Error when editing {{resource}} (status code: {{statusCode}})", "importProgress": "Importing: {{processed}}/{{total}}", "saveSuccessful": "Save successful!", + "saveFailed": "Save failed.", "validationError": "Validation error: {{error}}" }, "kofi": "Tip me on Ko-fi", @@ -326,7 +327,13 @@ }, "extra_fields": { "tab": "Extra Fields", - "description": "

Here you can add extra custom fields to your entities.

Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.

The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.

Extra fields can not be sorted or filtered in the table views.

", + "top_guidance": "In all extra field types, the key is an integration identifier used by APIs and external tools, so choose stable names. Default values apply only to newly created items.", + "custom": { + "header": "Custom Extra Fields", + "description": "Custom extra fields are fields you define directly (text, number, datetime, choice, and ranges). In custom extra fields, key and type are immutable after creation. For choice fields, existing choices and the multi-choice mode are also immutable to protect stored data. Deleting a field removes its data from all records.", + "description_intro": "Custom extra fields are fields you define directly", + "description_immutability": "In custom extra fields, key and type are immutable after creation. For choice fields, existing choices and the multi-choice mode are also immutable to protect stored data. Deleting a field removes its data from all records." + }, "params": { "key": "Key", "name": "Name", @@ -353,7 +360,161 @@ "non_unique_key_error": "The key must be unique.", "key_not_changed": "Please change the key to something else.", "delete_confirm": "Delete field {{name}}?", - "delete_confirm_description": "This will delete the field and all associated data for all entities." + "delete_confirm_description": "This will delete the field and all associated data for all entities.", + "delete_dependency_warning_intro": "Deleting this custom field will make dependent fields inoperable:", + "delete_dependency_warning_complex": "Complex extra fields: {{dependencies}}", + "delete_dependency_warning_formula": "Formula extra fields: {{dependencies}}", + "delete_dependency_warning_footer": "These entries remain saved, but behavior depending on this field will fail until references are updated." + }, + "complex_fields": { + "tab": "Complex Extra Fields", + "intro": "Complex extra fields are optional app-provided feature modules that extend built-in and custom fields for this entity. They are enabled here when available.", + "table_note": "Complex extra field definitions are provided by installed feature modules and are not created from this page.", + "missing_references_intro": "Some complex extra fields reference custom fields that are no longer available.", + "missing_references": "Missing custom extra field references: {{references}}", + "description": "

Complex fields add optional, pre-defined behaviors beyond custom extra fields.

Enabling one can add specialized display, actions, calculated values, or list columns for the selected entity. Disabled features remain hidden so the standard UI stays simpler.

Each entry below states exactly what enabling it adds. This framework can stay empty until a specific advanced feature is installed.

", + "tooltip": "Complex extra fields are app-provided feature modules. Enabling one can add specialized display, actions, calculated values, or list columns for this entity.", + "help_links": { + "complex": "Help: Complex Extra Fields", + "formula": "Help: Formula Extra Fields", + "formula_json": "Help: JSON Logic", + "formula_tokens": "Help: Token Groups" + }, + "empty": "No complex extra field feature modules are currently registered for this entity.", + "available_functions": { + "label": "Token categories:", + "value": "Operators, helper functions, and field references are grouped in the editor." + }, + "columns": { + "name": "Name", + "description": "Feature", + "enable_description": "What Enabling Adds", + "surfaces": "Display In", + "enabled": "Enabled" + }, + "surfaces": { + "show": "Show", + "edit": "Edit", + "list": "List", + "template": "Template", + "action": "Action", + "derived": "Derived" + }, + "messages": { + "enabled": "Enabled {{name}}.", + "disabled": "Disabled {{name}}." + }, + "formula": { + "header": "Formula Extra Fields", + "intro": "Formula extra fields are read-only derived values computed from expressions that reference existing fields.", + "evaluation_model_help": "Formula values are computed when records are loaded and are not stored as database columns. Dynamic helpers like today() refresh when data is reloaded.", + "description": "

Formula fields let you define your own calculated values for this entity.

Use references like {weight} or {created_at}, then combine them with math and helper functions. Formula fields are read-only and can be shown in list or show views.

This is where you can build calculations such as date parts, intervals, or specialized helpers like hue_from_hex(...).

", + "tooltip": "Formula extra fields are user-defined calculated values. Use field references with the available formula functions to build read-only values for the selected display areas.", + "empty": "No formula fields are currently defined for this entity.", + "columns": { + "key": "Key", + "name": "Name", + "description": "Description", + "result_type": "Result Type", + "expression": "Expression", + "expression_json": "Expression JSON (JSON Logic)", + "surfaces": "Display In", + "allow_list_column_toggle": "Hide Columns Toggle", + "include_in_api": "Include in API" + }, + "types": { + "number": "Number", + "text": "Text" + }, + "result_type_mismatch_hint": "Expression JSON appears to return {{inferred}}. You can keep the current type or auto-set it.", + "result_type_autoset": "Auto-set", + "tooltips": { + "key": "Stable machine key for this formula field. It must be unique, uses lowercase letters/numbers/underscores, and is what later integrations will refer to.", + "name": "Human-friendly label shown in the UI for this formula field.", + "display_in": "Choose where this formula field is intended to appear later. Show means detail pages. List means table or list views. Template means label, title, or filename template variables.", + "allow_list_column_toggle": "If enabled, list-display formula fields can be hidden or shown from the Hide Columns menu. If disabled, they stay visible whenever the field is shown in lists.", + "include_in_api": "When enabled, this field can be included in API responses under payload.derived whenever include_derived is enabled for the request.", + "expression_json": "JSON Logic object used to compute this formula field.", + "sample_values": "Variable definition examples and formatting: {\"weight\": 1000, \"remaining_weight\": 225, \"created_at\": \"2026-02-28T10:15:00Z\", \"color_hex\": \"#FF00FF\"}" + }, + "allow_list_column_toggle_help": "Only applies when List display is selected. This keeps optional formula columns discoverable in the existing Hide Columns picker.", + "allow_list_column_toggle_inline": "Enable column visibility in {{entity}} view.", + "sample_values": "Sample Values (JSON)", + "sample_values_help": "Define JSON variables for preview/testing of this expression.", + "expression_json_help": "Enter a JSON Logic object manually or using helper/reference tokens and operators to insert snippets.", + "expression_json_example": "Example: {\"-\": [{\"var\": \"weight\"}, {\"var\": \"remaining_weight\"}]}", + "expression_json_required": "Expression JSON (JSON Logic) is required.", + "key_usage_help": "API/template path", + "key_reserved_hint": "This key matches a formula token name ({{key}}). It still works, but choosing a distinct key can reduce confusion.", + "operator_groups": { + "logical": "Logical / Conditional", + "comparison": "Comparison", + "arithmetic": "Arithmetic", + "helpers": "Helpers" + }, + "token_sections": { + "operators": "Operators", + "helper_functions": "Helper Functions" + }, + "token_categories": { + "logical": "Logical / Conditional", + "comparison": "Comparison", + "arithmetic": "Arithmetic", + "math": "Math", + "text": "Text", + "datetime": "Date / Time", + "dynamic": "Dynamic", + "date_diff": "Date Diff", + "color": "Color" + }, + "reference_picker": { + "label": "Field References", + "placeholder": "Pick a field to insert into the expression", + "help": "Tokens are clickable inserts. Helper Functions are functional tokens, and Field References insert {var} references for built-in or configured extra fields." + }, + "json_builder": { + "operators_title": "Insert Tokens", + "click_to_insert_help": "Select a helper, then select compatible field references. Field references insert JSON snippets immediately. Use Helper only to insert placeholder arguments.", + "pending_helper": "Pending reference for helper {{helper}} ({{selected}}/{{total}})", + "pending_helper_prefix": "Pending reference for helper", + "pending_helper_count": "({{selected}}/{{total}})", + "helper_unavailable_reason": "Helper {{helper}} has no compatible references for this entity yet.", + "helper_incompatible_reason": "Helper {{helper}} is incompatible with the currently selected pending reference type.", + "reference_incompatible_reason": "Selected reference is incompatible with helper {{helper}}.", + "show_operators": "Show operators", + "hide_operators": "Hide operators", + "operator_compact": { + "logical_top": "Logical", + "logical_bottom": "Conditional", + "comparison": "Compare", + "math": "Math" + }, + "format": "Format JSON", + "format_tooltip": "Normalizes and pretty-prints the current JSON in the editor.", + "formatted": "Expression JSON formatted.", + "insert_without_reference_tooltip": "Insert helper with placeholder inputs and clear pending selection.", + "cancel_pending_tooltip": "Cancel pending helper selection.", + "helper_only": "Helper only" + }, + "delete_confirm": "Delete formula field {{name}}?", + "modal": { + "create_title": "New Formula Extra Field", + "edit_title": "Edit Formula Extra Field" + }, + "messages": { + "created": "Created {{name}}.", + "updated": "Updated {{name}}.", + "deleted": "Deleted {{name}}." + }, + "missing_references_intro": "Some formula fields reference custom fields that are no longer available.", + "missing_references": "Missing custom field references: {{references}}", + "preview": { + "button": "Preview Expression", + "result_label": "Preview:", + "references_used": "References used: {{references}}", + "no_references": "No field references are used in this expression." + } + } } }, "documentTitle": { diff --git a/client/src/index.tsx b/client/src/index.tsx index 75e4dcd06..06c084cc6 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -5,6 +5,28 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import "./i18n"; +const LOCAL_CACHE_BYPASS_HOSTS = new Set(["localhost", "127.0.0.1"]); +const shouldBypassLocalPwaCache = LOCAL_CACHE_BYPASS_HOSTS.has(window.location.hostname); + +if (shouldBypassLocalPwaCache) { + // Local PR validation should always reflect the newest bundle; clear service workers and + // their caches on localhost-style hosts to prevent stale UI from older test builds. + if ("serviceWorker" in navigator) { + void navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => { + void registration.unregister(); + }); + }); + } + if ("caches" in window) { + void caches.keys().then((keys) => { + keys.forEach((key) => { + void caches.delete(key); + }); + }); + } +} + const container = document.getElementById("root") as HTMLElement; const root = createRoot(container); diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 2d42198ce..fd05f9055 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -2,6 +2,7 @@ import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutl import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; import { Button, Dropdown, Table } from "antd"; +import { ColumnType } from "antd/es/table"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useMemo, useState } from "react"; @@ -17,6 +18,7 @@ import { SpoolIconColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; +import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { useSpoolmanArticleNumbers, useSpoolmanFilamentNames, @@ -24,8 +26,8 @@ import { useSpoolmanVendors, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; -import { EntityType, useGetFields } from "../../utils/queryFields"; -import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; +import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { useCurrencyFormatter } from "../../utils/settings"; import { IFilament } from "./model"; @@ -33,6 +35,7 @@ dayjs.extend(utc); interface IFilamentCollapsed extends Omit { "vendor.name": string | null; + derived?: Record; } function collapseFilament(element: IFilament): IFilamentCollapsed { @@ -77,12 +80,13 @@ export const FilamentList = () => { const invalidate = useInvalidate(); const navigate = useNavigate(); const extraFields = useGetFields(EntityType.filament); + const formulaFields = useGetDerivedFields(EntityType.filament); const currencyFormatter = useCurrencyFormatter(); - const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])]; - // Load initial state const initialState = useInitialTableState(namespace); + // Track formula-column hides separately so newly enabled toggleable fields still default to visible. + const [hiddenDerivedColumns, setHiddenDerivedColumns] = useSavedState(`${namespace}-hiddenDerivedColumns`, []); // Fetch data from the API // To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature. @@ -141,7 +145,41 @@ export const FilamentList = () => { () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], ); - const dataSource = useLiveify("filament", queryDataSource, collapseFilament); + const liveDataSource = useLiveify("filament", queryDataSource, collapseFilament); + const listFormulaFields = useMemo( + () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.list), + [formulaFields.data], + ); + const toggleableListFormulaFields = useMemo( + () => listFormulaFields.filter((field) => field.allow_list_column_toggle), + [listFormulaFields], + ); + const toggleableDerivedColumnKeys = useMemo( + () => toggleableListFormulaFields.map((field) => `derived.${field.key}`), + [toggleableListFormulaFields], + ); + const allColumnsWithExtraFields = useMemo( + () => [ + ...allColumns, + ...(extraFields.data?.map((field) => `extra.${field.key}`) ?? []), + ...toggleableDerivedColumnKeys, + ], + [extraFields.data, toggleableDerivedColumnKeys], + ); + const selectedColumnKeys = useMemo( + () => [...showColumns, ...toggleableDerivedColumnKeys.filter((key) => !hiddenDerivedColumns.includes(key))], + [hiddenDerivedColumns, showColumns, toggleableDerivedColumnKeys], + ); + const dataSource = useMemo( + () => + liveDataSource.map((record) => ({ + ...record, + // Formula values are computed client-side from the fetched row and are not persisted + // server-side fields, so they update on reload/live row updates and remain display-only. + derived: buildFormulaValues(record, listFormulaFields), + })), + [liveDataSource, listFormulaFields], + ); if (tableProps.pagination) { tableProps.pagination.showSizeChanger = true; @@ -165,6 +203,11 @@ export const FilamentList = () => { sorter: true, }; + const updateColumnSelections = (selectedKeys: string[]) => { + setShowColumns(selectedKeys.filter((key) => !toggleableDerivedColumnKeys.includes(key))); + setHiddenDerivedColumns(toggleableDerivedColumnKeys.filter((key) => !selectedKeys.includes(key))); + }; + return ( ( @@ -191,20 +234,27 @@ export const FilamentList = () => { label: extraField?.name ?? column_id, }; } + if (column_id.indexOf("derived.") === 0) { + const formulaField = toggleableListFormulaFields.find((field) => `derived.${field.key}` === column_id); + return { + key: column_id, + label: formulaField?.name ?? column_id, + }; + } return { key: column_id, label: t(translateColumnI18nKey(column_id)), }; }), - selectedKeys: showColumns, + selectedKeys: selectedColumnKeys, selectable: true, multiple: true, onDeselect: (keys) => { - setShowColumns(keys.selectedKeys); + updateColumnSelections(keys.selectedKeys.map(String)); }, onSelect: (keys) => { - setShowColumns(keys.selectedKeys); + updateColumnSelections(keys.selectedKeys.map(String)); }, }} > @@ -335,6 +385,21 @@ export const FilamentList = () => { field, }); }) ?? []), + ...listFormulaFields.map( + (field) => { + const derivedColumnKey = `derived.${field.key}`; + if (field.allow_list_column_toggle && hiddenDerivedColumns.includes(derivedColumnKey)) { + return undefined; + } + + return { + key: derivedColumnKey, + title: field.name, + width: 140, + render: (_: unknown, record: IFilamentCollapsed) => formatFormulaValue(record.derived?.[field.key]), + } as ColumnType; + }, + ), RichColumn({ ...commonProps, id: "comment", diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx index 4bc6c66be..4c98fe19d 100644 --- a/client/src/pages/filaments/show.tsx +++ b/client/src/pages/filaments/show.tsx @@ -1,3 +1,4 @@ +import { Fragment, useMemo } from "react"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useShow, useTranslate } from "@refinedev/core"; import { Button, Typography } from "antd"; @@ -7,8 +8,9 @@ import { useNavigate } from "react-router"; import { ExtraFieldDisplay } from "../../components/extraFields"; import { NumberFieldUnit } from "../../components/numberField"; import SpoolIcon from "../../components/spoolIcon"; +import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { enrichText } from "../../utils/parsing"; -import { EntityType, useGetFields } from "../../utils/queryFields"; +import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; import { IFilament } from "./model"; dayjs.extend(utc); @@ -19,6 +21,7 @@ export const FilamentShow = () => { const t = useTranslate(); const navigate = useNavigate(); const extraFields = useGetFields(EntityType.filament); + const formulaFields = useGetDerivedFields(EntityType.filament); const currencyFormatter = useCurrencyFormatter(); const { query } = useShow({ liveMode: "auto", @@ -26,6 +29,14 @@ export const FilamentShow = () => { const { data, isLoading } = query; const record = data?.data; + const showFormulaFields = useMemo( + () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.show), + [formulaFields.data], + ); + const derivedValues = useMemo( + () => (record ? buildFormulaValues(record, showFormulaFields) : {}), + [record, showFormulaFields], + ); const formatTitle = (item: IFilament) => { let vendorPrefix = ""; @@ -151,6 +162,13 @@ export const FilamentShow = () => { {extraFields?.data?.map((field, index) => ( ))} + {showFormulaFields.length > 0 && {t("settings.complex_fields.formula.header")}} + {showFormulaFields.map((field) => ( + + {field.name} + + + ))} ); }; diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx index bf493e6fd..3ee37bc86 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -1,27 +1,169 @@ import { FileOutlined, HighlightOutlined, UserOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; -import { List, theme } from "antd"; +import { Button, Col, Divider, Flex, List, Modal, Row, Space, Table, Tooltip, Typography, theme } from "antd"; import { Content } from "antd/es/layout/layout"; +import { ColumnsType } from "antd/es/table"; import Title from "antd/es/typography/Title"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; +import { useEffect, useMemo, useState } from "react"; import { Trans } from "react-i18next"; -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; +import { FORMULA_HELPER_GROUPS } from "../../utils/formulaFields"; dayjs.extend(utc); const { useToken } = theme; +const { Paragraph, Text } = Typography; + +type BuiltInEntity = "spool" | "filament" | "vendor"; + +type BuiltInFieldDefinition = { + key: string; + type: "text" | "integer" | "integer_range" | "float" | "float_range" | "datetime" | "boolean" | "choice"; + intent: string; +}; + +const BUILT_IN_FIELD_DEFINITIONS: Record = { + spool: [ + { key: "id", type: "integer", intent: "Stable system identifier for this spool record." }, + { key: "registered", type: "datetime", intent: "UTC timestamp for when the spool was first created in Spoolman." }, + { key: "first_used", type: "datetime", intent: "UTC timestamp of the first tracked filament usage event on this spool." }, + { key: "last_used", type: "datetime", intent: "UTC timestamp of the most recent tracked usage event on this spool." }, + { key: "filament", type: "choice", intent: "Linked filament profile this physical spool belongs to." }, + { key: "price", type: "float", intent: "Effective price for this spool, used for cost tracking and reporting." }, + { key: "initial_weight", type: "float", intent: "Starting net filament weight for this specific spool instance." }, + { key: "spool_weight", type: "float", intent: "Empty spool weight override used for measured-weight calculations." }, + { key: "remaining_weight", type: "float", intent: "Current estimated net filament remaining on the spool." }, + { key: "used_weight", type: "float", intent: "Current estimated net filament consumed from the spool." }, + { key: "remaining_length", type: "float", intent: "Current estimated filament length remaining on the spool." }, + { key: "used_length", type: "float", intent: "Current estimated filament length consumed from the spool." }, + { key: "location", type: "text", intent: "Storage or printer location label for organizing spool inventory." }, + { key: "lot_nr", type: "text", intent: "Manufacturer lot identifier used for traceability and color consistency." }, + { key: "comment", type: "text", intent: "Free-form operator notes for this spool." }, + { key: "archived", type: "boolean", intent: "Archive status flag used to hide inactive spools from normal workflows." }, + ], + filament: [ + { key: "id", type: "integer", intent: "Stable system identifier for this filament profile." }, + { key: "registered", type: "datetime", intent: "UTC timestamp for when the filament profile was created." }, + { key: "name", type: "text", intent: "Human-readable filament product name." }, + { key: "vendor", type: "choice", intent: "Linked manufacturer profile for this filament." }, + { key: "material", type: "text", intent: "Base material category such as PLA, PETG, ABS, or similar." }, + { key: "price", type: "float", intent: "Reference price for a full spool of this filament profile." }, + { key: "density", type: "float", intent: "Material density used for weight/length conversion math." }, + { key: "diameter", type: "float", intent: "Nominal filament diameter used for volume and length calculations." }, + { key: "weight", type: "float", intent: "Nominal net filament weight for a full spool." }, + { key: "spool_weight", type: "float", intent: "Nominal empty spool weight for measured-weight workflows." }, + { key: "article_number", type: "text", intent: "External catalog code such as SKU, UPC, or EAN." }, + { key: "settings_extruder_temp", type: "integer", intent: "Reference nozzle temperature for print profile setup." }, + { key: "settings_bed_temp", type: "integer", intent: "Reference bed temperature for print profile setup." }, + { key: "color_hex", type: "text", intent: "Primary hex color used for UI display and swatches." }, + { key: "multi_color_hexes", type: "text", intent: "Hex color list for multi-color filament definitions." }, + { key: "multi_color_direction", type: "choice", intent: "Multi-color layout mode, such as coextruded or longitudinal." }, + { key: "external_id", type: "text", intent: "Provider-specific identifier for external filament databases." }, + { key: "comment", type: "text", intent: "Free-form notes about this filament profile." }, + ], + vendor: [ + { key: "id", type: "integer", intent: "Stable system identifier for this manufacturer profile." }, + { key: "registered", type: "datetime", intent: "UTC timestamp for when the manufacturer profile was created." }, + { key: "name", type: "text", intent: "Manufacturer name used across linked filament profiles." }, + { key: "empty_spool_weight", type: "float", intent: "Default empty spool weight for this manufacturer." }, + { key: "external_id", type: "text", intent: "Provider-specific identifier for external manufacturer databases." }, + { key: "comment", type: "text", intent: "Free-form notes about this manufacturer profile." }, + ], +}; +const JSON_OPERATOR_GROUPS: Array<{ label: string; operators: string[] }> = [ + { label: "Logical / Conditional", operators: ["if", "and", "or", "!"] }, + { label: "Comparison", operators: ["==", "!=", "<", "<=", ">", ">="] }, + { label: "Arithmetic", operators: ["+", "-", "*", "/", "%"] }, +]; export const Help = () => { const { token } = useToken(); const t = useTranslate(); + const location = useLocation(); + const [builtInFieldEntity, setBuiltInFieldEntity] = useState(null); + const sectionBodyStyle = { fontSize: token.fontSize, lineHeight: 1.7 }; + const nestedLevel4Style = { marginLeft: 16 }; + const nestedLevel5Style = { marginLeft: 28 }; + const nestedLevel6Style = { marginLeft: 40 }; + + const renderLevel3Heading = (title: string, marginTop = 0) => ( + + + {title} + +
+ + ); + + const builtInFieldRows = useMemo(() => { + if (!builtInFieldEntity) { + return []; + } + return BUILT_IN_FIELD_DEFINITIONS[builtInFieldEntity]; + }, [builtInFieldEntity]); + + useEffect(() => { + if (!location.hash) { + return; + } + + const targetId = decodeURIComponent(location.hash.replace(/^#/, "")); + const scrollToTarget = () => { + const target = document.getElementById(targetId); + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + + // Route transitions can render asynchronously; run once immediately and once shortly after. + const animationFrame = requestAnimationFrame(scrollToTarget); + const timeout = window.setTimeout(scrollToTarget, 180); + return () => { + cancelAnimationFrame(animationFrame); + window.clearTimeout(timeout); + }; + }, [location.hash]); + + const builtInFieldColumns: ColumnsType<{ key: string; type: string; intent: string }> = [ + { + title: "Field", + dataIndex: "key", + key: "field", + width: "24%", + render: (value: string) => {value}, + }, + { + title: "Type", + dataIndex: "type", + key: "type", + width: "18%", + render: (value: string) => {value}, + }, + { + title: "Intent", + dataIndex: "intent", + key: "intent", + render: (value: string) => {value}, + }, + ]; + + const builtInFieldEntityTitle = + builtInFieldEntity === "spool" + ? "Spool" + : builtInFieldEntity === "filament" + ? "Filament" + : builtInFieldEntity === "vendor" + ? "Manufacturer" + : ""; return ( { ), }} /> + + + Field Overview + + + Spoolman includes built-in fields per entity and supports two extra field types:{" "} + Custom Extra Fields and Formula Extra Fields. + +
+ {renderLevel3Heading("Built-in Fields")} + + Built-in fields are core Spoolman attributes used by default forms, list columns, APIs, and label/template + references. + + + Open a quick field map: + + + + + + +
+ setBuiltInFieldEntity(null)} + width={900} + > + + +
+ {renderLevel3Heading("Extra Fields", 24)} + + Extra fields let you store additional data directly and define user-maintained derived values across + entities. + + + Configure definitions in{" "} + Settings → Extra Fields → Spools,{" "} + Filaments, and{" "} + Manufacturers. + +
+
+ + Custom Extra Fields + + + Custom extra fields store direct values that you enter or import for each entity record. + + + Supported types include text, integer, integer_range,{" "} + float, float_range, datetime,{" "} + boolean, and choice. + + + In custom extra fields, key and type are immutable after creation. For choice fields, existing choices and + the multi-choice mode are also immutable. Deleting a field removes its data from all records. + + + Keys should stay stable because APIs and integrations use them as identifiers. Default values apply only to + newly created items. + +
+
+ + Formula Extra Fields + + + Formula extra fields turn existing built-in/custom data into calculated values you can reuse everywhere. + Common examples are spool age, normalized date labels, cost deltas, and short text tags. + + + They are read-only outputs configured per entity, so source records stay unchanged. The primary authoring + format is Expression JSON (JSON Logic). + + + Configure them in Settings → Extra Fields for Spools, Filaments, or + Manufacturers. In Formula Extra Fields, click +, build your JSON + expression, then validate with Sample Values (JSON) and Preview Expression{" "} + before saving. + + + In each formula field editor, use Include in API to mark that field as eligible for API + output. Entity responses include only those field-level opt-ins under a derived object + whenever include_derived=true is requested. Each field key is exposed as{" "} + {`derived.`}. + + + Formula values are computed when records are loaded and are not stored as dedicated database columns. Dynamic + helpers such as today() refresh when data is reloaded. Enabling API derived output can add + response compute time on large lists. Per request, clients can override the default with{" "} + include_derived=true or include_derived=false. + + + JSON Logic + + + Token groups are clickable inserts that speed up authoring and reduce JSON syntax mistakes. + + + Field References insert JSON Logic variable objects. For example,{" "} + {`{weight}`} inserts {`{"var":"weight"}`} and{" "} + {`{extra.purchase_date}`} inserts {`{"var":"extra.purchase_date"}`}. + + + Operators insert operator templates, and Helper Functions insert + helper templates that can be completed with compatible field references. + + + Helper insertion is staged: click a helper first, then click the required compatible references. While a + helper is pending, incompatible helper/reference tokens are visible but dimmed. Use X to + cancel pending helper selection, or Helper only to insert that helper with placeholder + inputs. + + + Formula-to-formula references are not supported. Build nested JSON Logic in a single formula instead of + referencing another formula field. Formula outputs are available in API/template usage via{" "} + {`derived.`}. + + + On wider layouts, operators are shown in a right-side panel next to the JSON editor and can be collapsed or + expanded. On narrow layouts, operators are hidden from the panel and can still be entered directly in JSON. + + +
+ + Token Groups + +
+ + Operators + + + {JSON_OPERATOR_GROUPS.map((group) => ( +
+
+ {group.label} +
+ + {group.operators.map((operator) => ( + + {operator} + + ))} + +
+
+ + ))} + + + + Helper Functions + + + {FORMULA_HELPER_GROUPS.map((group) => ( + +
+ {t(`settings.complex_fields.formula.token_categories.${group.key}`)} +
+ + {group.helpers.map((helper) => ( + + {helper.name} + + ))} + +
+
+ + ))} + + + + + + Concrete Examples + + + Variables come from available field references for the selected entity, including built-in fields + (for example {`{created_at}`}) and custom fields + (for example {`{extra.purchase_date}`}). + + +
+ Example 1: Full timestamp to YYYY-MM-DD + + Variable definitions: + + {`{"created_at":"2026-03-09T14:23:45Z"}`} + + Expression JSON: + + {`{"date_only":[{"var":"created_at"}]}`} + + Result: {`"2026-03-09"`} + +
+
+ Example 2: Difference between two datetimes + + Variable definitions: + + {`{"first_used":"2026-03-01T10:00:00Z","last_used":"2026-03-09T16:00:00Z"}`} + + Expression JSON: + + {`{"days_between":[{"var":"first_used"},{"var":"last_used"}]}`} + + Result: {`8.25`} + +
+
+ Example 3: Short text label from lot number + + Variable definitions: + + {`{"lot_nr":"ABCD-23991"}`} + + Expression JSON: + + {`{"left":[{"var":"lot_nr"},4]}`} + + Result: {`"ABCD"`} + +
+
+ + + + Formatting & Validation + + + The expression editor uses a JSON code editor (CodeMirror). Use Format JSON to + auto-pretty-print your JSON Logic object. Keep Preview Expression +{" "} + Sample Values (JSON) as your first validation pass. + + + Sample Values (JSON) must be a valid JSON object used only for preview/testing. Use plain + keys without braces, and match keys to your {`{"var":"..."}`} references. Example:{" "} + {`{"weight": 1000, "remaining_weight": 225, "created_at": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}`}. + + + Reference docs:{" "} + + jsonlogic.com + + {" · "} + + operations + + {" · "} + + JSONLint + + + + Choose where each formula appears: Show (record details), List{" "} + (table/list pages), and Template (label/title/filename templates). + +
    +
  • + If a formula includes List, you can enable column toggling so it can be hidden or shown + through Hide Columns on list pages. +
  • +
  • + If a formula includes Template, it can be referenced in templates as{" "} + {`{derived.your_key}`} (for example, {`{derived.days_between_events}`}). +
  • +
+ ); }; diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 865c597cf..b3f9ab366 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -4,7 +4,8 @@ import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography import TextArea from "antd/es/input/TextArea"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; -import { EntityType, useGetFields } from "../../utils/queryFields"; +import { buildFormulaValues, getTemplateFormulaFields } from "../../utils/formulaFields"; +import { EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { useGetSetting } from "../../utils/querySettings"; import { useSavedState } from "../../utils/saveload"; import { useGetSpoolsByIds } from "../spools/functions"; @@ -182,11 +183,15 @@ Spool Weight: {filament.spool_weight} g { tag: "archived" }, ]; const spoolFields = useGetFields(EntityType.spool); + const spoolDerivedFields = useGetDerivedFields(EntityType.spool); if (spoolFields.data !== undefined) { spoolFields.data.forEach((field) => { spoolTags.push({ tag: `extra.${field.key}` }); }); } + getTemplateFormulaFields(spoolDerivedFields.data).forEach((field) => { + spoolTags.push({ tag: `derived.${field.key}` }); + }); const filamentTags = [ { tag: "filament.id" }, { tag: "filament.registered" }, @@ -207,11 +212,15 @@ Spool Weight: {filament.spool_weight} g { tag: "filament.external_id" }, ]; const filamentFields = useGetFields(EntityType.filament); + const filamentDerivedFields = useGetDerivedFields(EntityType.filament); if (filamentFields.data !== undefined) { filamentFields.data.forEach((field) => { filamentTags.push({ tag: `filament.extra.${field.key}` }); }); } + getTemplateFormulaFields(filamentDerivedFields.data).forEach((field) => { + filamentTags.push({ tag: `filament.derived.${field.key}` }); + }); const vendorTags = [ { tag: "filament.vendor.id" }, { tag: "filament.vendor.registered" }, @@ -221,14 +230,44 @@ Spool Weight: {filament.spool_weight} g { tag: "filament.vendor.external_id" }, ]; const vendorFields = useGetFields(EntityType.vendor); + const vendorDerivedFields = useGetDerivedFields(EntityType.vendor); if (vendorFields.data !== undefined) { vendorFields.data.forEach((field) => { vendorTags.push({ tag: `filament.vendor.extra.${field.key}` }); }); } + getTemplateFormulaFields(vendorDerivedFields.data).forEach((field) => { + vendorTags.push({ tag: `filament.vendor.derived.${field.key}` }); + }); const templateTags = [...spoolTags, ...filamentTags, ...vendorTags]; + const templateReadySpools = items.map((spool) => { + const filament = spool.filament; + const vendor = filament.vendor; + + const spoolDerived = buildFormulaValues(spool, getTemplateFormulaFields(spoolDerivedFields.data)); + const filamentDerived = buildFormulaValues(filament, getTemplateFormulaFields(filamentDerivedFields.data)); + const vendorDerived = vendor + ? buildFormulaValues(vendor, getTemplateFormulaFields(vendorDerivedFields.data)) + : {}; + + return { + ...spool, + derived: spoolDerived, + filament: { + ...filament, + derived: filamentDerived, + vendor: vendor + ? { + ...vendor, + derived: vendorDerived, + } + : vendor, + }, + }; + }); + return ( <> {contextHolder} @@ -299,7 +338,7 @@ Spool Weight: {filament.spool_weight} g } - items={items.map((spool) => ({ + items={templateReadySpools.map((spool) => ({ value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `WEB+SPOOLMAN:S-${spool.id}`, label: (

= { + vendor: ["id", "name", "registered", "comment"], + filament: ["id", "name", "material", "price", "density", "weight", "color_hex", "comment"], + spool: ["id", "weight", "remaining_weight", "used_weight", "price", "lot_nr", "comment", "created_at"], +}; +const SAMPLE_VALUE_PLACEHOLDERS: Record = { + vendor: '{"name": "Example Vendor", "registered": "2026-02-28T10:15:00Z"}', + filament: '{"weight": 1000, "material": "PLA", "created_at": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}', + spool: '{"weight": 1000, "remaining_weight": 225, "created_at": "2026-02-28T10:15:00Z"}', +}; +const JSON_LOGIC_OPERATOR_GROUPS: Array<{ key: string; operators: string[] }> = [ + { key: "logical", operators: ["if", "and", "or", "!"] }, + { key: "comparison", operators: ["==", "!=", "<", "<=", ">", ">="] }, + { key: "arithmetic", operators: ["+", "-", "*", "/", "%"] }, +]; +const OPERATOR_PANEL_WIDTH = 244; +const INLINE_OPERATOR_PANEL_HEIGHT = 264; +const HELPER_DESKTOP_COLUMN_LAYOUT: Array<{ top: string; bottom?: string }> = [ + { top: "math", bottom: "color" }, + { top: "text" }, + { top: "datetime" }, + { top: "dynamic", bottom: "date_diff" }, +]; +const JSON_LOGIC_OPERATOR_SNIPPETS: Record = { + if: '{\n "if": [\n {"var": "condition"},\n "then_value",\n "else_value"\n ]\n}', + and: '{\n "and": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + or: '{\n "or": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "!": '{\n "!": [\n {"var": "value"}\n ]\n}', + "==": '{\n "==": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "!=": '{\n "!=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "<": '{\n "<": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "<=": '{\n "<=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + ">": '{\n ">": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + ">=": '{\n ">=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "+": '{\n "+": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "-": '{\n "-": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "*": '{\n "*": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "/": '{\n "/": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "%": '{\n "%": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', +}; +const RESERVED_DERIVED_KEY_NAMES = new Set([ + ...JSON_LOGIC_OPERATOR_GROUPS.flatMap((group) => group.operators), + ...FORMULA_HELPERS.map((helper) => helper.name), +]); + +type ReferenceValueKind = "any" | "number" | "datetime" | "text" | "boolean" | "range" | "unknown"; +type PendingHelperInsertState = { + helperName: string; + selectedReferences: string[]; +}; +type FormulaResultTypeHint = "number" | "text" | "boolean" | "unknown"; + +const BUILTIN_REFERENCE_KIND_HINTS: Record> = { + vendor: { + id: "number", + name: "text", + registered: "datetime", + comment: "text", + }, + filament: { + id: "number", + name: "text", + material: "text", + price: "number", + density: "number", + weight: "number", + color_hex: "text", + comment: "text", + }, + spool: { + id: "number", + weight: "number", + remaining_weight: "number", + used_weight: "number", + price: "number", + lot_nr: "text", + comment: "text", + created_at: "datetime", + }, +}; + +function resolveColorLuminance(color: string): number | null { + const normalized = color.trim().toLowerCase(); + + const hexMatch = normalized.match(/^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/); + if (hexMatch) { + const hex = hexMatch[1]; + const value = + hex.length === 3 || hex.length === 4 + ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + : hex.slice(0, 6); + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + } + + const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/); + if (!rgbMatch) { + return null; + } + const channels = rgbMatch[1] + .split(",") + .map((part) => part.trim()) + .slice(0, 3); + if (channels.length !== 3) { + return null; + } + + const toByte = (channel: string): number | null => { + if (channel.endsWith("%")) { + const percent = Number(channel.slice(0, -1)); + if (Number.isNaN(percent)) { + return null; + } + return Math.round((Math.max(0, Math.min(100, percent)) / 100) * 255); + } + const value = Number(channel); + if (Number.isNaN(value)) { + return null; + } + return Math.max(0, Math.min(255, value)); + }; + + const r = toByte(channels[0]); + const g = toByte(channels[1]); + const b = toByte(channels[2]); + if (r == null || g == null || b == null) { + return null; + } + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +function formatPreviewValue(value: string | number | boolean | null): string { + if (value === null) { + return "null"; + } + return `${value}`; +} + +function parseSampleValues(raw: string | undefined): Record { + if (!raw || raw.trim() === "") { + return {}; + } + + const parsed = JSON.parse(raw); + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("Sample values must be a JSON object."); + } + + return parsed as Record; +} + +function parseExpressionJson(raw: string | undefined): Record | undefined { + if (!raw || raw.trim() === "") { + return undefined; + } + + const parsed = JSON.parse(raw); + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("Expression JSON must be a JSON object."); + } + return parsed as Record; +} + +function mergeTypeHints(typeHints: FormulaResultTypeHint[]): FormulaResultTypeHint { + const knownHints = typeHints.filter((typeHint) => typeHint !== "unknown"); + if (knownHints.length === 0) { + return "unknown"; + } + return knownHints.every((typeHint) => typeHint === knownHints[0]) ? knownHints[0] : "unknown"; +} + +function inferExpressionJsonType(node: unknown): FormulaResultTypeHint { + if (typeof node === "number") { + return "number"; + } + if (typeof node === "string") { + return "text"; + } + if (typeof node === "boolean") { + return "boolean"; + } + if (node === null || Array.isArray(node) || typeof node !== "object") { + return "unknown"; + } + + const entries = Object.entries(node as Record); + if (entries.length !== 1) { + return "unknown"; + } + + const [operator, rawArgs] = entries[0]; + const args = Array.isArray(rawArgs) ? rawArgs : [rawArgs]; + + if (operator === "var") { + return "unknown"; + } + + if (operator === "if") { + const branchHints: FormulaResultTypeHint[] = []; + for (let index = 1; index < args.length; index += 2) { + branchHints.push(inferExpressionJsonType(args[index])); + } + if (args.length % 2 === 0 && args.length > 0) { + branchHints.push(inferExpressionJsonType(args[args.length - 1])); + } + return mergeTypeHints(branchHints); + } + + if (operator === "coalesce") { + return mergeTypeHints(args.map((arg) => inferExpressionJsonType(arg))); + } + + if (["==", "!=", "<", "<=", ">", ">=", "!", "and", "or"].includes(operator)) { + return "boolean"; + } + + if ( + [ + "+", + "-", + "*", + "/", + "%", + "abs", + "min", + "max", + "round", + "year", + "month", + "day", + "hour", + "minute", + "second", + "timestamp", + "days_between", + "hours_between", + "hue_from_hex", + "length", + ].includes(operator) + ) { + return "number"; + } + + if (["date_only", "time_only", "today", "cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes(operator)) { + return "text"; + } + + return "unknown"; +} + +function toDerivedFieldType(typeHint: FormulaResultTypeHint): DerivedFieldType | null { + if (typeHint === "number") { + return DerivedFieldType.number; + } + if (typeHint === "text") { + return DerivedFieldType.text; + } + return null; +} + +export function FormulaFieldsSettings() { + const { entityType } = useParams<{ entityType: EntityType }>(); + const t = useTranslate(); + const { token } = theme.useToken(); + const screens = Grid.useBreakpoint(); + const [messageApi, contextHolder] = message.useMessage(); + const [derivedModalOpen, setDerivedModalOpen] = useState(false); + const [editingDerivedKey, setEditingDerivedKey] = useState(null); + const [previewText, setPreviewText] = useState(null); + const [previewReferences, setPreviewReferences] = useState([]); + const [pendingJsonHelperInsert, setPendingJsonHelperInsert] = useState(null); + const [resultTypeMismatchHint, setResultTypeMismatchHint] = useState(null); + const [operatorPanelCollapsed, setOperatorPanelCollapsed] = useState(false); + const [hoveredTokenId, setHoveredTokenId] = useState(null); + const [derivedForm] = Form.useForm(); + const expressionJsonEditorRef = useRef(null); + const expressionJsonSelectionRef = useRef<{ from: number; to: number }>({ from: 0, to: 0 }); + + const selectedEntityType = entityType as EntityType; + const niceName = t(`${selectedEntityType}.${selectedEntityType}`); + const sectionBodyStyle = { marginTop: 0, fontSize: token.fontSize, lineHeight: 1.7 }; + const tokenPanelStyle = useMemo( + () => ({ + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + padding: 10, + background: token.colorBgContainer, + }), + [token.colorBgContainer, token.colorBorderSecondary, token.borderRadiusLG], + ); + const tokenCategoryStyle = useMemo( + () => ({ + borderRadius: token.borderRadius, + border: `1px solid ${token.colorBorderSecondary}`, + background: token.colorFillQuaternary, + padding: "8px 10px", + minHeight: 68, + }), + [token.borderRadius, token.colorBorderSecondary, token.colorFillQuaternary], + ); + const tokenListStyle = useMemo( + () => ({ + display: "flex", + flexWrap: "wrap", + gap: 6, + marginTop: 6, + justifyContent: "center", + }), + [], + ); + const compactHelperCategoryStyle = useMemo( + () => ({ + padding: "6px", + minHeight: 52, + }), + [], + ); + const compactHelperTokenListStyle = useMemo( + () => ({ + ...tokenListStyle, + justifyContent: "center", + marginTop: 4, + gap: 4, + }), + [tokenListStyle], + ); + const referenceGridStyle = useMemo( + () => ({ + display: "grid", + // Keep references dense while predictable: 4 columns on desktop, 3/2 on medium widths, 1 on mobile. + gridTemplateColumns: screens.lg || screens.xl || screens.xxl + ? "repeat(4, minmax(0, 1fr))" + : screens.md + ? "repeat(3, minmax(0, 1fr))" + : screens.sm + ? "repeat(2, minmax(0, 1fr))" + : "repeat(1, minmax(0, 1fr))", + gap: 6, + }), + [screens.lg, screens.md, screens.sm, screens.xl, screens.xxl], + ); + const isDesktopLayout = Boolean(screens.lg || screens.xl || screens.xxl); + const isDesktopOperatorPanel = isDesktopLayout; + const showInlineOperatorPanel = Boolean(isDesktopOperatorPanel && !operatorPanelCollapsed); + const codeMirrorTheme = useMemo(() => { + const bgLuminance = resolveColorLuminance(token.colorBgContainer); + const textLuminance = resolveColorLuminance(token.colorText); + const isDark = bgLuminance != null ? bgLuminance < 0.5 : (textLuminance ?? 0) > 0.6; + // Use Ant warning background tokens so selection follows the theme palette, + // but stays muted enough for multiline editing in dark mode. + const selectionColor = isDark ? token.colorWarningBg : token.colorWarningBgHover; + const selectionMatchColor = isDark ? "rgba(250, 173, 20, 0.24)" : "rgba(250, 173, 20, 0.18)"; + const activeLineColor = isDark ? "rgba(250, 173, 20, 0.02)" : "rgba(22, 119, 255, 0.04)"; + const activeLineGutterColor = isDark ? "rgba(250, 173, 20, 0.04)" : "rgba(22, 119, 255, 0.06)"; + // Force editor foreground/background directly from design tokens so formula JSON remains + // readable in both light and dark themes regardless of global CSS inheritance. + return EditorView.theme( + { + "&": { + backgroundColor: token.colorBgContainer, + color: token.colorText, + borderRadius: token.borderRadius, + border: `1px solid ${token.colorBorder}`, + }, + "&.cm-editor": { + backgroundColor: token.colorBgContainer, + color: token.colorText, + }, + "& .cm-scroller": { + backgroundColor: token.colorBgContainer, + color: token.colorText, + }, + "&.cm-focused": { + outline: `1px solid ${token.colorPrimaryBorderHover}`, + }, + ".cm-scroller": { + fontFamily: token.fontFamilyCode || "monospace", + }, + ".cm-content, .cm-line": { + color: token.colorText, + caretColor: token.colorText, + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: token.colorText, + }, + // Keep bracket feedback enabled (standard editor behavior) but align it to amber theme. + ".cm-matchingBracket": { + backgroundColor: `${selectionMatchColor} !important`, + color: token.colorText, + outline: `1px solid ${token.colorWarningBorder}`, + borderRadius: 2, + }, + ".cm-nonmatchingBracket": { + backgroundColor: "transparent !important", + color: token.colorError, + outline: `1px solid ${token.colorErrorBorder}`, + borderRadius: 2, + }, + // Keep selection/search matches enabled (standard behavior), but use the same amber family. + ".cm-content .cm-selectionMatch, .cm-content .cm-searchMatch, .cm-content .cm-searchMatch-selected": { + backgroundColor: `${selectionMatchColor} !important`, + outline: `1px solid ${token.colorWarningBorder}`, + border: "none !important", + borderRadius: 2, + boxShadow: "none !important", + }, + ".cm-gutters": { + backgroundColor: token.colorBgElevated, + color: token.colorTextTertiary, + borderRight: `1px solid ${token.colorBorderSecondary}`, + }, + ".cm-activeLine": { + backgroundColor: activeLineColor, + }, + ".cm-activeLineGutter": { + backgroundColor: activeLineGutterColor, + }, + ".cm-selectionLayer": { + mixBlendMode: "normal", + }, + // Force one consistent drawn selection color for both focused and blurred states. + ".cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground, &.cm-focused .cm-selectionBackground, &.cm-focused .cm-selectionLayer .cm-selectionBackground": { + backgroundColor: `${selectionColor} !important`, + borderRadius: 2, + }, + // Keep native browser selection transparent so it doesn't override with platform colors. + ".cm-content ::selection, .cm-line ::selection, .cm-line > span::selection, .cm-content *::selection": { + backgroundColor: "transparent !important", + }, + }, + { dark: isDark }, + ); + }, [ + token.borderRadius, + token.colorBgContainer, + token.colorBgElevated, + token.colorBorder, + token.colorBorderSecondary, + token.colorError, + token.colorErrorBorder, + token.colorPrimaryBorderHover, + token.colorText, + token.colorTextTertiary, + token.colorWarningBorder, + token.colorWarningBg, + token.colorWarningBgHover, + token.fontFamilyCode, + ]); + useEffect(() => { + if (!isDesktopOperatorPanel) { + setOperatorPanelCollapsed(true); + return; + } + setOperatorPanelCollapsed(false); + }, [isDesktopOperatorPanel, derivedModalOpen]); + + const derivedFields = useGetDerivedFields(selectedEntityType); + const configuredFields = useGetFields(selectedEntityType); + const setDerivedField = useSetDerivedField(selectedEntityType); + const deleteDerivedField = useDeleteDerivedField(selectedEntityType); + const previewDerivedField = usePreviewDerivedField(selectedEntityType); + const expressionJsonValue = Form.useWatch("expression_json", derivedForm) as string | undefined; + const derivedKeyValue = ((Form.useWatch("key", derivedForm) as string | undefined) || "").trim(); + // Show the concrete API/template path for the currently typed key to remove + // ambiguity between formula operator names and field output identifiers. + const derivedKeyPath = useMemo( + () => (derivedKeyValue ? `derived.${derivedKeyValue}` : "derived."), + [derivedKeyValue], + ); + const keyLooksLikeReservedToken = useMemo( + () => RESERVED_DERIVED_KEY_NAMES.has(derivedKeyValue), + [derivedKeyValue], + ); + + const sampleValuesPlaceholder = SAMPLE_VALUE_PLACEHOLDERS[selectedEntityType]; + + const labeledField = (labelKey: string, tooltipKey: string) => ( + + {t(labelKey)} + + + + + ); + + const referenceOptions = useMemo(() => { + const extraReferences = (configuredFields.data || []).map((field) => `extra.${field.key}`); + // Suggest both built-in fields and configured extra fields so users can compose formulas + // without memorizing the exact reference syntax for each entity. + return [...new Set([...BUILTIN_REFERENCE_SUGGESTIONS[selectedEntityType], ...extraReferences])]; + }, [configuredFields.data, selectedEntityType]); + const compactReferenceOptions = useMemo( + () => + referenceOptions.map((reference) => ({ + value: reference, + label: `{${reference}}`, + })), + [referenceOptions], + ); + const helperByName = useMemo( + () => Object.fromEntries(FORMULA_HELPERS.map((helper) => [helper.name, helper] as const)), + [], + ); + const operatorGroups = useMemo( + () => + JSON_LOGIC_OPERATOR_GROUPS.map((group) => ({ + ...group, + label: t(`settings.complex_fields.formula.token_categories.${group.key}`), + })), + [t], + ); + const helperGroups = useMemo( + () => + FORMULA_HELPER_GROUPS.map((group) => ({ + ...group, + label: t(`settings.complex_fields.formula.token_categories.${group.key}`), + })), + [t], + ); + const helperGroupByKey = useMemo( + () => Object.fromEntries(helperGroups.map((group) => [group.key, group])), + [helperGroups], + ); + const referenceKindByName = useMemo(() => { + const map: Record = { + ...BUILTIN_REFERENCE_KIND_HINTS[selectedEntityType], + }; + + (configuredFields.data || []).forEach((field) => { + const fieldKind: ReferenceValueKind = (() => { + switch (field.field_type) { + case FieldType.integer: + case FieldType.float: + return "number"; + case FieldType.datetime: + return "datetime"; + case FieldType.boolean: + return "boolean"; + case FieldType.integer_range: + case FieldType.float_range: + return "range"; + case FieldType.text: + case FieldType.choice: + return "text"; + default: + return "unknown"; + } + })(); + map[`extra.${field.key}`] = fieldKind; + }); + + return map; + }, [configuredFields.data, selectedEntityType]); + const getHelperReferenceCount = (helper: FormulaHelperDefinition): number => { + if (helper.insert_mode === "none") { + return 0; + } + return helper.reference_count ?? 1; + }; + const helperAllowsReferenceKind = (helper: FormulaHelperDefinition, referenceKind: ReferenceValueKind): boolean => { + const requiredKind = helper.reference_kind ?? "any"; + if (requiredKind === "any") { + return true; + } + return referenceKind === requiredKind; + }; + const pendingHelperDefinition = useMemo(() => { + if (!pendingJsonHelperInsert) { + return null; + } + return helperByName[pendingJsonHelperInsert.helperName] || null; + }, [helperByName, pendingJsonHelperInsert]); + const getHelperDisabledReason = (helper: FormulaHelperDefinition): string | null => { + if (helper.insert_mode === "none") { + return null; + } + + const requiredRefCount = getHelperReferenceCount(helper); + const compatibleReferences = referenceOptions.filter((reference) => + helperAllowsReferenceKind(helper, referenceKindByName[reference] || "unknown"), + ); + if (compatibleReferences.length < requiredRefCount) { + return t("settings.complex_fields.formula.json_builder.helper_unavailable_reason", { helper: helper.name }); + } + + // When the user already picked reference #1 for a pending helper, temporarily disable helper + // tokens that can't accept that selected reference kind. Clearing/completing pending insert + // resets all helper tokens back to normal. + if (pendingJsonHelperInsert?.selectedReferences.length) { + const selectedKind = referenceKindByName[pendingJsonHelperInsert.selectedReferences[0]] || "unknown"; + if (!helperAllowsReferenceKind(helper, selectedKind)) { + return t("settings.complex_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); + } + } + + return null; + }; + const isReferenceCompatibleWithPendingHelper = (reference: string): boolean => { + if (!pendingHelperDefinition) { + return true; + } + const referenceKind = referenceKindByName[reference] || "unknown"; + return helperAllowsReferenceKind(pendingHelperDefinition, referenceKind); + }; + const buildHelperPlaceholderArguments = (helper: FormulaHelperDefinition): Array<{ var: string }> => { + const referenceCount = getHelperReferenceCount(helper); + if (referenceCount <= 0) { + return []; + } + if (referenceCount === 1) { + return [{ var: "value" }]; + } + if (referenceCount === 2) { + return [{ var: "start" }, { var: "end" }]; + } + return Array.from({ length: referenceCount }, (_, index) => ({ var: `arg_${index + 1}` })); + }; + const helperTokenGridStyle = useMemo( + () => ({ + display: "grid", + // Desktop uses a dedicated stacked helper layout; this fallback handles narrower widths. + gridTemplateColumns: screens.md || screens.sm ? "repeat(2, minmax(0, 1fr))" : "repeat(1, minmax(0, 1fr))", + gap: 8, + alignItems: "start", + }), + [screens.md, screens.sm], + ); + const renderTokenCategory = ( + key: string, + label: string, + tokens: ReactNode, + style?: CSSProperties, + tokenContainerStyle?: CSSProperties, + ) => ( +

+ + {label} + +
{tokens}
+
+ ); + const renderOperatorTokenGroups = (interactive: boolean) => ( +
+ {operatorGroups.map((group) => { + const compactTitle = + group.key === "logical" + ? ( + <> + {t("settings.complex_fields.formula.json_builder.operator_compact.logical_top")} +
+ {t("settings.complex_fields.formula.json_builder.operator_compact.logical_bottom")} + + ) + : group.key === "comparison" + ? t("settings.complex_fields.formula.json_builder.operator_compact.comparison") + : t("settings.complex_fields.formula.json_builder.operator_compact.math"); + const operatorGridColumns = group.key === "logical" ? "repeat(2, max-content)" : "repeat(3, max-content)"; + const labelColumnWidth = group.key === "logical" ? 90 : 78; + return ( +
+
+ {group.operators.map((operator) => ( + (() => { + const tokenId = `operator-${group.key}-${operator}`; + const isHovered = hoveredTokenId === tokenId; + return ( + setHoveredTokenId(tokenId) : undefined} + onMouseLeave={interactive ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} + onClick={interactive ? () => insertExpressionJsonOperator(operator) : undefined} + > + {operator} + + ); + })() + ))} +
+ + + {compactTitle} + + +
+ ); + })} +
+ ); + const renderHelperTokenCategory = ( + groupKey: string, + interactive: boolean, + compact = false, + ) => { + const group = helperGroupByKey[groupKey]; + if (!group || group.helpers.length === 0) { + return null; + } + return renderTokenCategory( + group.key, + group.label, + group.helpers.map((helper) => { + const disabledReason = interactive ? getHelperDisabledReason(helper) : null; + const tokenId = `helper-${helper.name}`; + const isHovered = hoveredTokenId === tokenId; + const helperToken = ( + setHoveredTokenId(tokenId) : undefined} + onMouseLeave={interactive && !disabledReason ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} + onClick={interactive && !disabledReason ? () => insertExpressionJsonHelper(helper) : undefined} + > + {helper.name} + + ); + return ( + + {helperToken} + + ); + }), + compact ? compactHelperCategoryStyle : undefined, + compact ? compactHelperTokenListStyle : undefined, + ); + }; + const renderHelperTokenGroups = (interactive: boolean) => { + if (isDesktopLayout) { + return ( +
+ {HELPER_DESKTOP_COLUMN_LAYOUT.map((column) => ( +
+ {renderHelperTokenCategory(column.top, interactive)} + {column.bottom ? renderHelperTokenCategory(column.bottom, interactive, true) : null} +
+ ))} +
+ ); + } + return ( +
+ {helperGroups.map((group) => renderHelperTokenCategory(group.key, interactive))} +
+ ); + }; + + const missingCustomReferencesByDerivedField = useMemo(() => { + const availableCustomFieldKeys = new Set((configuredFields.data || []).map((field) => field.key)); + const missingMap: Record = {}; + + (derivedFields.data || []).forEach((derivedField) => { + const missingReferences = getExtraFieldReferences(derivedField.expression_json || undefined).filter( + (reference) => !availableCustomFieldKeys.has(reference), + ); + if (missingReferences.length > 0) { + missingMap[derivedField.key] = missingReferences; + } + }); + + return missingMap; + }, [configuredFields.data, derivedFields.data]); + + const hasBrokenFormulaDependencies = useMemo( + () => Object.keys(missingCustomReferencesByDerivedField).length > 0, + [missingCustomReferencesByDerivedField], + ); + + const openCreateDerived = () => { + setEditingDerivedKey(null); + setPreviewText(null); + setPreviewReferences([]); + setResultTypeMismatchHint(null); + derivedForm.resetFields(); + derivedForm.setFieldsValue({ + key: "", + name: "", + description: "", + result_type: DerivedFieldType.number, + surfaces: [ComplexFieldSurface.show], + allow_list_column_toggle: false, + include_in_api: false, + expression_json: "", + sample_values: "{}", + }); + setDerivedModalOpen(true); + }; + + const openEditDerived = (record: DerivedField) => { + setEditingDerivedKey(record.key); + setPreviewText(null); + setPreviewReferences([]); + setResultTypeMismatchHint(null); + derivedForm.setFieldsValue({ + key: record.key, + name: record.name, + description: record.description || "", + result_type: record.result_type, + surfaces: record.surfaces, + allow_list_column_toggle: record.allow_list_column_toggle, + include_in_api: record.include_in_api ?? false, + expression_json: record.expression_json ? JSON.stringify(record.expression_json, null, 2) : "", + sample_values: "{}", + }); + setDerivedModalOpen(true); + }; + + const closeDerivedModal = () => { + setDerivedModalOpen(false); + setEditingDerivedKey(null); + setPreviewText(null); + setPreviewReferences([]); + setResultTypeMismatchHint(null); + setPendingJsonHelperInsert(null); + expressionJsonSelectionRef.current = { from: 0, to: 0 }; + derivedForm.resetFields(); + }; + + const insertExpressionJsonSnippet = (snippet: string) => { + const currentValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + const hasEditor = expressionJsonEditorRef.current !== null; + if (!hasEditor) { + const prefix = currentValue.trim() === "" ? "" : "\n"; + derivedForm.setFieldValue("expression_json", `${currentValue}${prefix}${snippet}`); + return; + } + + const start = expressionJsonSelectionRef.current.from; + const end = expressionJsonSelectionRef.current.to; + const nextValue = `${currentValue.slice(0, start)}${snippet}${currentValue.slice(end)}`; + const nextCursor = start + snippet.length; + derivedForm.setFieldValue("expression_json", nextValue); + expressionJsonSelectionRef.current = { from: nextCursor, to: nextCursor }; + + requestAnimationFrame(() => { + const editor = expressionJsonEditorRef.current; + if (!editor) { + return; + } + editor.focus(); + editor.dispatch({ + selection: { anchor: nextCursor, head: nextCursor }, + scrollIntoView: true, + }); + }); + }; + + const insertExpressionJsonReference = (reference: string) => { + if (!pendingHelperDefinition) { + insertExpressionJsonSnippet(JSON.stringify({ var: reference }, null, 2)); + return; + } + + if (!isReferenceCompatibleWithPendingHelper(reference)) { + messageApi.warning( + t("settings.complex_fields.formula.json_builder.reference_incompatible_reason", { + helper: pendingHelperDefinition.name, + }), + ); + return; + } + + const pendingState = pendingJsonHelperInsert; + if (!pendingState) { + return; + } + const requiredReferenceCount = getHelperReferenceCount(pendingHelperDefinition); + const selectedReferences = [...pendingState.selectedReferences, reference]; + if (selectedReferences.length < requiredReferenceCount) { + setPendingJsonHelperInsert({ + helperName: pendingHelperDefinition.name, + selectedReferences, + }); + return; + } + + const snippet = { + [pendingHelperDefinition.name]: selectedReferences + .slice(0, requiredReferenceCount) + .map((selectedReference) => ({ var: selectedReference })), + }; + // Insert ready-to-parse JSON Logic objects so users can build expressions without memorizing + // raw AST syntax. + insertExpressionJsonSnippet(JSON.stringify(snippet, null, 2)); + setPendingJsonHelperInsert(null); + }; + + const insertExpressionJsonHelper = (helper: FormulaHelperDefinition) => { + if (helper.insert_mode === "none") { + insertExpressionJsonSnippet(JSON.stringify({ [helper.name]: [] }, null, 2)); + setPendingJsonHelperInsert(null); + return; + } + const disabledReason = getHelperDisabledReason(helper); + if (disabledReason) { + messageApi.warning(disabledReason); + return; + } + // Keep helper insertion staged until required reference tokens are selected, so helpers with + // multiple reference operands (for example days_between/hours_between) can be assembled safely. + setPendingJsonHelperInsert({ helperName: helper.name, selectedReferences: [] }); + messageApi.info( + t("settings.complex_fields.formula.json_builder.pending_helper", { + helper: helper.name, + selected: 0, + total: getHelperReferenceCount(helper), + }), + ); + }; + + const insertPendingHelperWithoutReferences = () => { + if (!pendingHelperDefinition) { + return; + } + const placeholderSnippet = { + [pendingHelperDefinition.name]: buildHelperPlaceholderArguments(pendingHelperDefinition), + }; + insertExpressionJsonSnippet(JSON.stringify(placeholderSnippet, null, 2)); + setPendingJsonHelperInsert(null); + }; + const cancelPendingHelperInsert = () => { + setPendingJsonHelperInsert(null); + }; + + const insertExpressionJsonOperator = (operator: string) => { + const snippet = JSON_LOGIC_OPERATOR_SNIPPETS[operator]; + if (!snippet) { + return; + } + insertExpressionJsonSnippet(snippet); + setPendingJsonHelperInsert(null); + }; + + const formatExpressionJson = async () => { + try { + const currentValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + const parsed = parseExpressionJson(currentValue); + if (!parsed) { + setResultTypeMismatchHint(null); + return; + } + const selectedResultType = derivedForm.getFieldValue("result_type") as DerivedFieldType | undefined; + const inferredType = toDerivedFieldType(inferExpressionJsonType(parsed)); + if (selectedResultType && inferredType && inferredType !== selectedResultType) { + setResultTypeMismatchHint(inferredType); + } else { + setResultTypeMismatchHint(null); + } + derivedForm.setFieldValue("expression_json", JSON.stringify(parsed, null, 2)); + messageApi.success(t("settings.complex_fields.formula.json_builder.formatted")); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + const saveDerived = async () => { + try { + const values = await derivedForm.validateFields(); + const key = editingDerivedKey || values.key; + const expressionJson = parseExpressionJson(values.expression_json); + if (!expressionJson) { + throw new Error(t("settings.complex_fields.formula.expression_json_required")); + } + if (expressionJson) { + const inferredType = toDerivedFieldType(inferExpressionJsonType(expressionJson)); + if (inferredType && inferredType !== values.result_type) { + setResultTypeMismatchHint(inferredType); + } else { + setResultTypeMismatchHint(null); + } + } else { + setResultTypeMismatchHint(null); + } + + await setDerivedField.mutateAsync({ + key, + params: { + name: values.name, + description: values.description || undefined, + result_type: values.result_type, + expression_json: expressionJson, + surfaces: values.surfaces, + allow_list_column_toggle: values.allow_list_column_toggle, + include_in_api: values.include_in_api ?? false, + }, + }); + + messageApi.success( + t(editingDerivedKey ? "settings.complex_fields.formula.messages.updated" : "settings.complex_fields.formula.messages.created", { + name: values.name, + }), + ); + closeDerivedModal(); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + const previewDerived = async () => { + try { + const values = await derivedForm.validateFields(["expression_json", "sample_values"]); + const sampleValues = parseSampleValues(values.sample_values); + const expressionJson = parseExpressionJson(values.expression_json); + if (!expressionJson) { + throw new Error(t("settings.complex_fields.formula.expression_json_required")); + } + // Preview uses sample JSON only as a sandbox for validating formulas before they are exposed + // on show/list/template surfaces. + const preview = await previewDerivedField.mutateAsync({ + expression_json: expressionJson, + sample_values: sampleValues, + }); + + setPreviewText(formatPreviewValue(preview.result)); + setPreviewReferences(preview.references); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + const removeDerived = async (record: DerivedField) => { + try { + await deleteDerivedField.mutateAsync(record.key); + messageApi.success(t("settings.complex_fields.formula.messages.deleted", { name: record.name })); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + const derivedColumns: ColumnType[] = [ + { + title: t("settings.complex_fields.formula.columns.key"), + dataIndex: "key", + key: "key", + width: "12%", + }, + { + title: t("settings.complex_fields.formula.columns.name"), + dataIndex: "name", + key: "name", + width: "16%", + }, + { + title: t("settings.complex_fields.formula.columns.result_type"), + dataIndex: "result_type", + key: "result_type", + width: "10%", + render: (value: DerivedFieldType) => t(`settings.complex_fields.formula.types.${value}`), + }, + { + title: t("settings.complex_fields.formula.columns.expression"), + dataIndex: "expression_json", + key: "expression", + width: "30%", + render: (_value: Record | undefined, record) => { + const expressionValue = record.expression_json ? JSON.stringify(record.expression_json) : ""; + const missingReferences = missingCustomReferencesByDerivedField[record.key] || []; + return ( + + + {expressionValue} + + {missingReferences.length > 0 && ( + + {t("settings.complex_fields.formula.missing_references", { + references: missingReferences.join(", "), + })} + + )} + + ); + }, + }, + { + title: t("settings.complex_fields.formula.columns.surfaces"), + dataIndex: "surfaces", + key: "surfaces", + width: "16%", + render: (surfaces: string[]) => ( + + {surfaces.map((surface) => ( + {t(`settings.complex_fields.surfaces.${surface}`)} + ))} + + ), + }, + { + title: t("settings.complex_fields.formula.columns.include_in_api"), + dataIndex: "include_in_api", + key: "include_in_api", + width: "10%", + render: (value: boolean) => (value ? API : {t("no")}), + }, + { + title: "", + key: "operation", + width: "16%", + render: (_: unknown, record) => ( + + + removeDerived(record)} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + + + + ), + }, + ]; + + const previewSummary = useMemo(() => { + if (previewText == null) { + return null; + } + + const referencesText = + previewReferences.length > 0 + ? t("settings.complex_fields.formula.preview.references_used", { + references: previewReferences.join(", "), + }) + : t("settings.complex_fields.formula.preview.no_references"); + + return ( + + {t("settings.complex_fields.formula.preview.result_label")} {previewText} +
+ {referencesText} +
+ ); + }, [previewReferences, previewText, t]); + const pendingHelperHint = useMemo(() => { + if (!pendingHelperDefinition || !pendingJsonHelperInsert) { + return null; + } + const selected = pendingJsonHelperInsert.selectedReferences.length; + const total = getHelperReferenceCount(pendingHelperDefinition); + return { + helper: pendingHelperDefinition.name, + selected, + total, + }; + }, [pendingHelperDefinition, pendingJsonHelperInsert]); + return ( + <> + + + + {t("settings.complex_fields.formula.header")}: {niceName} + + + + + + {t("settings.complex_fields.help_links.formula")} + + + + + {t("settings.complex_fields.formula.intro")} + + + {t("settings.complex_fields.formula.evaluation_model_help")} + + {hasBrokenFormulaDependencies && ( + + {t("settings.complex_fields.formula.missing_references_intro")} + + )} + + {t("settings.complex_fields.available_functions.value")} + +
+ ), + }} + onRow={(record) => { + const hasMissingReferences = (missingCustomReferencesByDerivedField[record.key] || []).length > 0; + if (!hasMissingReferences) { + return {}; + } + return { + style: { + backgroundColor: token.colorErrorBg, + }, + }; + }} + rowKey="key" + /> + + + + + {t("settings.complex_fields.formula.key_usage_help")}: {derivedKeyPath} + + {keyLooksLikeReservedToken && ( + + + {t("settings.complex_fields.formula.key_reserved_hint", { key: derivedKeyValue })} + + )} + + )} + rules={[ + { required: true, min: 1, max: 64, pattern: /^[a-z0-9_]+$/ }, + { + validator: async (_, value) => { + if (!editingDerivedKey && derivedFields.data?.some((field) => field.key === value)) { + throw new Error(t("settings.extra_fields.non_unique_key_error")); + } + }, + }, + ]} + > + + + + + + + + + + + + + {/* Keep Display In aligned with Result Type on desktop while preserving form order when stacked. */} + + + + {t("settings.complex_fields.formula.columns.result_type")} + {resultTypeMismatchHint && ( + <> + + + + + + )} + + )} + name="result_type" + rules={[{ required: true }]} + > + ({ + label: t(`settings.complex_fields.surfaces.${surface}`), + value: surface, + }))} + onChange={(selected: string[]) => { + if (!selected.includes(ComplexFieldSurface.list)) { + derivedForm.setFieldValue("allow_list_column_toggle", false); + } + }} + /> + + + {({ getFieldValue }) => { + const selectedSurfaces = (getFieldValue("surfaces") as string[] | undefined) || []; + const listEnabled = selectedSurfaces.includes(ComplexFieldSurface.list); + if (!listEnabled) { + return null; + } + + return ( + + + + + + {t("settings.complex_fields.formula.allow_list_column_toggle_inline", { entity: niceName })} + + + ); + }} + + + + + + + + + {labeledField( + "settings.complex_fields.formula.columns.expression_json", + "settings.complex_fields.formula.tooltips.expression_json", + )} + + {t("settings.complex_fields.help_links.formula_json")} + + + + + {t("settings.complex_fields.formula.expression_json_help")} + + + {t("settings.complex_fields.formula.expression_json_example")} + + + } + name="expression_json" + trigger="onChange" + getValueFromEvent={(value: string) => value} + rules={[ + { + validator: async (_, value) => { + const parsed = parseExpressionJson(value); + if (!parsed) { + throw new Error(t("settings.complex_fields.formula.expression_json_required")); + } + }, + }, + ]} + > +
+ {isDesktopOperatorPanel && ( + + + + + +
+ {showInlineOperatorPanel && ( +
+ + {t("settings.complex_fields.formula.token_sections.operators")} + +
{renderOperatorTokenGroups(true)}
+
+ )} + + +
+ {/* Show helper/operators before references so helper-first insertion flow is visually guided. */} + + + + + {t("settings.complex_fields.formula.json_builder.operators_title")} + + + {t("settings.complex_fields.help_links.formula_tokens")} + + + +
+ +
+ + + {t("settings.complex_fields.formula.token_sections.helper_functions")} + + {pendingHelperHint ? ( + + + {t("settings.complex_fields.formula.json_builder.pending_helper_prefix")} + + + {pendingHelperHint.helper} + + + {t("settings.complex_fields.formula.json_builder.pending_helper_count", { + selected: pendingHelperHint.selected, + total: pendingHelperHint.total, + })} + + + + + + ) : null} + +
{renderHelperTokenGroups(true)}
+
+
+ + {t("settings.complex_fields.formula.reference_picker.label")} + +
+
+ {compactReferenceOptions.map((reference) => { + const referenceCompatible = isReferenceCompatibleWithPendingHelper(reference.value); + const isSelectedForPendingHelper = Boolean( + pendingJsonHelperInsert?.selectedReferences.includes(reference.value), + ); + const disabledReason = + !referenceCompatible && pendingHelperDefinition + ? t("settings.complex_fields.formula.json_builder.reference_incompatible_reason", { + helper: pendingHelperDefinition.name, + }) + : null; + const referenceToken = ( + setHoveredTokenId(`reference-${reference.value}`) : undefined} + onMouseLeave={!disabledReason ? () => setHoveredTokenId((current) => (current === `reference-${reference.value}` ? null : current)) : undefined} + onClick={!disabledReason ? () => insertExpressionJsonReference(reference.value) : undefined} + > + {reference.label} + + ); + // Keep a stable wrapper shape for all reference tokens so disabled/tooltip states + // do not cause reflow when helper compatibility changes. + const content = ( + + {referenceToken} + + ); + return ( +
+ {content} +
+ ); + })} +
+
+
+
+
+ + {t("settings.complex_fields.formula.json_builder.click_to_insert_help")} + +
+ { + parseSampleValues(value); + }, + }, + ]} + > + + + + + + + {labeledField( + "settings.complex_fields.formula.columns.include_in_api", + "settings.complex_fields.formula.tooltips.include_in_api", + )} + + + + + + {previewSummary} + + + + {contextHolder} + + ); +} diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index 76599ea76..7902b3e72 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -3,6 +3,7 @@ import { useTranslate } from "@refinedev/core"; import { Button, Checkbox, + Divider, Flex, Form, FormInstance, @@ -12,7 +13,9 @@ import { Select, Space, Table, + Typography, message, + theme, } from "antd"; import { FormItemProps, Rule } from "antd/es/form"; import { ColumnType } from "antd/es/table"; @@ -20,12 +23,21 @@ import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { useState } from "react"; -import { Trans } from "react-i18next"; +import { useMemo, useState } from "react"; import { useParams } from "react-router"; import { DateTimePicker } from "../../components/dateTimePicker"; import { InputNumberRange } from "../../components/inputNumberRange"; -import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields"; +import { getExtraFieldReferences } from "../../utils/formulaFields"; +import { FormulaFieldsSettings } from "./complexFieldsSettings"; +import { + EntityType, + Field, + FieldType, + useDeleteField, + useGetDerivedFields, + useGetFields, + useSetField, +} from "../../utils/queryFields"; dayjs.extend(utc); dayjs.extend(timezone); @@ -286,8 +298,10 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps export function ExtraFieldsSettings() { const { entityType } = useParams<{ entityType: EntityType }>(); const t = useTranslate(); + const { token } = theme.useToken(); const [form] = Form.useForm(); const fields = useGetFields(entityType as EntityType); + const derivedFields = useGetDerivedFields(entityType as EntityType); const setField = useSetField(entityType as EntityType); const deleteField = useDeleteField(entityType as EntityType); const [isSubmitting, setIsSubmitting] = useState(false); @@ -296,6 +310,7 @@ export function ExtraFieldsSettings() { const [messageApi, contextHolder] = message.useMessage(); const [editingKey, setEditingKey] = useState(""); + const sectionBodyStyle = { fontSize: token.fontSize, lineHeight: 1.7 }; const isEditing = (record: FieldHolder) => record.field.key === editingKey; @@ -443,7 +458,22 @@ export function ExtraFieldsSettings() { }; const niceName = t(`${entityType}.${entityType}`); - + const formulaDependenciesByCustomFieldKey = useMemo(() => { + const dependencies: Record = {}; + (derivedFields.data || []).forEach((derivedField) => { + const referencedCustomFields = getExtraFieldReferences(derivedField.expression_json || undefined); + referencedCustomFields.forEach((customFieldKey) => { + if (!dependencies[customFieldKey]) { + dependencies[customFieldKey] = []; + } + dependencies[customFieldKey].push({ + key: derivedField.key, + name: derivedField.name, + }); + }); + }); + return dependencies; + }, [derivedFields.data]); const columns: ColumnType[] = [ { title: t("settings.extra_fields.params.key"), @@ -547,18 +577,44 @@ export function ExtraFieldsSettings() { - del(record.field)} - disabled={editingKey !== ""} - okText={t("buttons.delete")} - cancelText={t("buttons.cancel")} - > - - + {(() => { + const formulaDependencies = formulaDependenciesByCustomFieldKey[record.field.key] || []; + const hasFormulaDependencies = formulaDependencies.length > 0; + const formulaDependencyList = formulaDependencies.map((item) => `${item.name} (${item.key})`).join(", "); + const confirmDescription = hasFormulaDependencies ? ( + + + {t("settings.extra_fields.delete_confirm_description", { name: record.field.name })} + + {t("settings.extra_fields.delete_dependency_warning_intro")} + {hasFormulaDependencies && ( + + {t("settings.extra_fields.delete_dependency_warning_formula", { + dependencies: formulaDependencyList, + })} + + )} + {t("settings.extra_fields.delete_dependency_warning_footer")} + + ) : ( + t("settings.extra_fields.delete_confirm_description", { name: record.field.name }) + ); + + return ( + del(record.field)} + disabled={editingKey !== ""} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + + + ); + })()} ); @@ -601,12 +657,20 @@ export function ExtraFieldsSettings() {

{t("settings.extra_fields.tab")} - {niceName}

- , - }} - /> + + {t("settings.extra_fields.top_guidance")} + + + {t("settings.extra_fields.custom.header")}: {niceName} + + + {t("settings.extra_fields.custom.description_intro")} ( + text, integer,{" "} + integer_range, float,{" "} + float_range, datetime,{" "} + boolean, choice).{" "} + {t("settings.extra_fields.custom.description_immutability")} +
)} + {contextHolder} ); diff --git a/client/src/pages/settings/index.tsx b/client/src/pages/settings/index.tsx index 4393addfa..098a51c63 100644 --- a/client/src/pages/settings/index.tsx +++ b/client/src/pages/settings/index.tsx @@ -19,7 +19,6 @@ export const Settings = () => { const getCurrentKey = () => { const path = window.location.pathname.replace("/settings", ""); - // Remove starting slash and ending slash if exists and return return path.replace(/^\/|\/$/g, ""); }; diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 9ee11a3ed..e710f1368 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -11,6 +11,7 @@ import { import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; import { Button, Dropdown, Modal, Table } from "antd"; +import { ColumnType } from "antd/es/table"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useCallback, useMemo, useState } from "react"; @@ -27,6 +28,7 @@ import { SpoolIconColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; +import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { useSpoolmanFilamentFilter, useSpoolmanLocations, @@ -34,7 +36,7 @@ import { useSpoolmanMaterials, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; -import { EntityType, useGetFields } from "../../utils/queryFields"; +import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { useCurrencyFormatter } from "../../utils/settings"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; @@ -48,6 +50,7 @@ interface ISpoolCollapsed extends ISpool { "filament.combined_name": string; // Eg. "Prusa - PLA Red" "filament.id": number; "filament.material"?: string; + derived?: Record; } function collapseSpool(element: ISpool): ISpoolCollapsed { @@ -102,16 +105,17 @@ export const SpoolList = () => { const invalidate = useInvalidate(); const navigate = useNavigate(); const extraFields = useGetFields(EntityType.spool); + const formulaFields = useGetDerivedFields(EntityType.spool); const currencyFormatter = useCurrencyFormatter(); const { openSpoolAdjustModal, spoolAdjustModal } = useSpoolAdjustModal(); - const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])]; - // Load initial state const initialState = useInitialTableState(namespace); // State for the switch to show archived spools const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false); + // Track formula-column hides separately so newly enabled toggleable fields still default to visible. + const [hiddenDerivedColumns, setHiddenDerivedColumns] = useSavedState(`${namespace}-hiddenDerivedColumns`, []); // Fetch data from the API // To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature. @@ -175,7 +179,41 @@ export const SpoolList = () => { () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], ); - const dataSource = useLiveify("spool", queryDataSource, collapseSpool); + const liveDataSource = useLiveify("spool", queryDataSource, collapseSpool); + const listFormulaFields = useMemo( + () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.list), + [formulaFields.data], + ); + const toggleableListFormulaFields = useMemo( + () => listFormulaFields.filter((field) => field.allow_list_column_toggle), + [listFormulaFields], + ); + const toggleableDerivedColumnKeys = useMemo( + () => toggleableListFormulaFields.map((field) => `derived.${field.key}`), + [toggleableListFormulaFields], + ); + const allColumnsWithExtraFields = useMemo( + () => [ + ...allColumns, + ...(extraFields.data?.map((field) => `extra.${field.key}`) ?? []), + ...toggleableDerivedColumnKeys, + ], + [extraFields.data, toggleableDerivedColumnKeys], + ); + const selectedColumnKeys = useMemo( + () => [...showColumns, ...toggleableDerivedColumnKeys.filter((key) => !hiddenDerivedColumns.includes(key))], + [hiddenDerivedColumns, showColumns, toggleableDerivedColumnKeys], + ); + const dataSource = useMemo( + () => + liveDataSource.map((record) => ({ + ...record, + // Formula values are computed client-side from the fetched row and are not persisted + // server-side fields, so they update on reload/live row updates and remain display-only. + derived: buildFormulaValues(record, listFormulaFields), + })), + [liveDataSource, listFormulaFields], + ); // Function for opening an ant design modal that asks for confirmation for archiving a spool const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => { @@ -256,6 +294,11 @@ export const SpoolList = () => { sorter: true, }; + const updateColumnSelections = (selectedKeys: string[]) => { + setShowColumns(selectedKeys.filter((key) => !toggleableDerivedColumnKeys.includes(key))); + setHiddenDerivedColumns(toggleableDerivedColumnKeys.filter((key) => !selectedKeys.includes(key))); + }; + return ( ( @@ -300,20 +343,27 @@ export const SpoolList = () => { label: extraField?.name ?? column_id, }; } + if (column_id.indexOf("derived.") === 0) { + const formulaField = toggleableListFormulaFields.find((field) => `derived.${field.key}` === column_id); + return { + key: column_id, + label: formulaField?.name ?? column_id, + }; + } return { key: column_id, label: t(translateColumnI18nKey(column_id)), }; }), - selectedKeys: showColumns, + selectedKeys: selectedColumnKeys, selectable: true, multiple: true, onDeselect: (keys) => { - setShowColumns(keys.selectedKeys); + updateColumnSelections(keys.selectedKeys.map(String)); }, onSelect: (keys) => { - setShowColumns(keys.selectedKeys); + updateColumnSelections(keys.selectedKeys.map(String)); }, }} > @@ -457,6 +507,21 @@ export const SpoolList = () => { field, }); }) ?? []), + ...listFormulaFields.map( + (field) => { + const derivedColumnKey = `derived.${field.key}`; + if (field.allow_list_column_toggle && hiddenDerivedColumns.includes(derivedColumnKey)) { + return undefined; + } + + return { + key: derivedColumnKey, + title: field.name, + width: 140, + render: (_: unknown, record: ISpoolCollapsed) => formatFormulaValue(record.derived?.[field.key]), + } as ColumnType; + }, + ), RichColumn({ ...commonProps, id: "comment", diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index 9f6ba59d6..e86a70c93 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -1,3 +1,4 @@ +import { Fragment, useMemo } from "react"; import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useInvalidate, useShow, useTranslate } from "@refinedev/core"; @@ -7,8 +8,9 @@ import utc from "dayjs/plugin/utc"; import { ExtraFieldDisplay } from "../../components/extraFields"; import { NumberFieldUnit } from "../../components/numberField"; import SpoolIcon from "../../components/spoolIcon"; +import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { enrichText } from "../../utils/parsing"; -import { EntityType, useGetFields } from "../../utils/queryFields"; +import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; import { getBasePath } from "../../utils/url"; import { IFilament } from "../filaments/model"; @@ -23,6 +25,7 @@ const { confirm } = Modal; export const SpoolShow = () => { const t = useTranslate(); const extraFields = useGetFields(EntityType.spool); + const formulaFields = useGetDerivedFields(EntityType.spool); const currencyFormatter = useCurrencyFormatter(); const invalidate = useInvalidate(); @@ -32,6 +35,14 @@ export const SpoolShow = () => { const { data, isLoading } = query; const record = data?.data; + const showFormulaFields = useMemo( + () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.show), + [formulaFields.data], + ); + const derivedValues = useMemo( + () => (record ? buildFormulaValues(record, showFormulaFields) : {}), + [record, showFormulaFields], + ); const spoolPrice = (item?: ISpool) => { const price = item?.price ?? item?.filament.price; @@ -223,6 +234,13 @@ export const SpoolShow = () => { {extraFields?.data?.map((field, index) => ( ))} + {showFormulaFields.length > 0 && {t("settings.complex_fields.formula.header")}} + {showFormulaFields.map((field) => ( + + {field.name} + + + ))} ); }; diff --git a/client/src/pages/vendors/list.tsx b/client/src/pages/vendors/list.tsx index 8fc3cb468..7ceb000c0 100644 --- a/client/src/pages/vendors/list.tsx +++ b/client/src/pages/vendors/list.tsx @@ -2,6 +2,7 @@ import { EditOutlined, EyeOutlined, FilterOutlined, PlusSquareOutlined } from "@ import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; import { Button, Dropdown, Table } from "antd"; +import { ColumnType } from "antd/es/table"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useCallback, useMemo, useState } from "react"; @@ -15,9 +16,10 @@ import { SortedColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; +import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { removeUndefined } from "../../utils/filtering"; -import { EntityType, useGetFields } from "../../utils/queryFields"; -import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; +import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { IVendor } from "./model"; dayjs.extend(utc); @@ -31,11 +33,12 @@ export const VendorList = () => { const invalidate = useInvalidate(); const navigate = useNavigate(); const extraFields = useGetFields(EntityType.vendor); - - const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])]; + const formulaFields = useGetDerivedFields(EntityType.vendor); // Load initial state const initialState = useInitialTableState(namespace); + // Track formula-column hides separately so newly enabled toggleable fields still default to visible. + const [hiddenDerivedColumns, setHiddenDerivedColumns] = useSavedState(`${namespace}-hiddenDerivedColumns`, []); // Fetch data from the API const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } = @@ -82,11 +85,45 @@ export const VendorList = () => { const queryDataSource: IVendor[] = useMemo(() => { return (tableProps.dataSource || []).map((record) => ({ ...record })); }, [tableProps.dataSource]); - const dataSource = useLiveify( + const liveDataSource = useLiveify( "vendor", queryDataSource, useCallback((record: IVendor) => record, []), ); + const listFormulaFields = useMemo( + () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.list), + [formulaFields.data], + ); + const toggleableListFormulaFields = useMemo( + () => listFormulaFields.filter((field) => field.allow_list_column_toggle), + [listFormulaFields], + ); + const toggleableDerivedColumnKeys = useMemo( + () => toggleableListFormulaFields.map((field) => `derived.${field.key}`), + [toggleableListFormulaFields], + ); + const allColumnsWithExtraFields = useMemo( + () => [ + ...allColumns, + ...(extraFields.data?.map((field) => `extra.${field.key}`) ?? []), + ...toggleableDerivedColumnKeys, + ], + [extraFields.data, toggleableDerivedColumnKeys], + ); + const selectedColumnKeys = useMemo( + () => [...showColumns, ...toggleableDerivedColumnKeys.filter((key) => !hiddenDerivedColumns.includes(key))], + [hiddenDerivedColumns, showColumns, toggleableDerivedColumnKeys], + ); + const dataSource = useMemo( + () => + liveDataSource.map((record) => ({ + ...record, + // Formula values are computed client-side from the fetched row and are not persisted + // server-side fields, so they update on reload/live row updates and remain display-only. + derived: buildFormulaValues(record, listFormulaFields), + })), + [liveDataSource, listFormulaFields], + ); if (tableProps.pagination) { tableProps.pagination.showSizeChanger = true; @@ -108,6 +145,11 @@ export const VendorList = () => { sorter: true, }; + const updateColumnSelections = (selectedKeys: string[]) => { + setShowColumns(selectedKeys.filter((key) => !toggleableDerivedColumnKeys.includes(key))); + setHiddenDerivedColumns(toggleableDerivedColumnKeys.filter((key) => !selectedKeys.includes(key))); + }; + return ( ( @@ -134,20 +176,27 @@ export const VendorList = () => { label: extraField?.name ?? column_id, }; } + if (column_id.indexOf("derived.") === 0) { + const formulaField = toggleableListFormulaFields.find((field) => `derived.${field.key}` === column_id); + return { + key: column_id, + label: formulaField?.name ?? column_id, + }; + } return { key: column_id, label: t(`vendor.fields.${column_id}`), }; }), - selectedKeys: showColumns, + selectedKeys: selectedColumnKeys, selectable: true, multiple: true, onDeselect: (keys) => { - setShowColumns(keys.selectedKeys); + updateColumnSelections(keys.selectedKeys.map(String)); }, onSelect: (keys) => { - setShowColumns(keys.selectedKeys); + updateColumnSelections(keys.selectedKeys.map(String)); }, }} > @@ -198,6 +247,22 @@ export const VendorList = () => { field, }); }) ?? []), + ...listFormulaFields.map( + (field) => { + const derivedColumnKey = `derived.${field.key}`; + if (field.allow_list_column_toggle && hiddenDerivedColumns.includes(derivedColumnKey)) { + return undefined; + } + + return { + key: derivedColumnKey, + title: field.name, + width: 140, + render: (_: unknown, record: IVendor) => + formatFormulaValue((record as IVendor & { derived?: Record }).derived?.[field.key]), + } as ColumnType; + }, + ), RichColumn({ ...commonProps, id: "comment", diff --git a/client/src/pages/vendors/show.tsx b/client/src/pages/vendors/show.tsx index 1fc49110a..9404c2c55 100644 --- a/client/src/pages/vendors/show.tsx +++ b/client/src/pages/vendors/show.tsx @@ -1,11 +1,13 @@ +import { Fragment, useMemo } from "react"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useShow, useTranslate } from "@refinedev/core"; import { Typography } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { ExtraFieldDisplay } from "../../components/extraFields"; +import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { enrichText } from "../../utils/parsing"; -import { EntityType, useGetFields } from "../../utils/queryFields"; +import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { IVendor } from "./model"; dayjs.extend(utc); @@ -15,6 +17,7 @@ const { Title } = Typography; export const VendorShow = () => { const t = useTranslate(); const extraFields = useGetFields(EntityType.vendor); + const formulaFields = useGetDerivedFields(EntityType.vendor); const { query } = useShow({ liveMode: "auto", @@ -22,6 +25,14 @@ export const VendorShow = () => { const { data, isLoading } = query; const record = data?.data; + const showFormulaFields = useMemo( + () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.show), + [formulaFields.data], + ); + const derivedValues = useMemo( + () => (record ? buildFormulaValues(record, showFormulaFields) : {}), + [record, showFormulaFields], + ); const formatTitle = (item: IVendor) => { return t("vendor.titles.show_title", { id: item.id, name: item.name, interpolation: { escapeValue: false } }); @@ -49,6 +60,13 @@ export const VendorShow = () => { {extraFields?.data?.map((field, index) => ( ))} + {showFormulaFields.length > 0 && {t("settings.complex_fields.formula.header")}} + {showFormulaFields.map((field) => ( + + {field.name} + + + ))} ); }; diff --git a/client/src/utils/formulaFields.ts b/client/src/utils/formulaFields.ts new file mode 100644 index 000000000..95c04cfb9 --- /dev/null +++ b/client/src/utils/formulaFields.ts @@ -0,0 +1,536 @@ +import { ComplexFieldSurface, DerivedField } from "./queryFields"; + +type FormulaScope = object; + +export type FormulaHelperDefinition = { + name: string; + description: string; + category: FormulaHelperCategory; + insert_mode?: "reference" | "none"; + reference_count?: number; + reference_kind?: "any" | "number" | "datetime" | "text"; +}; + +export type FormulaHelperCategory = "math" | "text" | "datetime" | "dynamic" | "date_diff" | "color"; + +export type FormulaHelperGroupDefinition = { + key: FormulaHelperCategory; + helpers: FormulaHelperDefinition[]; +}; + +export const FORMULA_HELPERS: FormulaHelperDefinition[] = [ + { name: "abs", description: "Returns the absolute value of a number.", category: "math", reference_kind: "number" }, + { name: "min", description: "Returns the smallest value from the provided arguments.", category: "math", reference_kind: "number" }, + { name: "max", description: "Returns the largest value from the provided arguments.", category: "math", reference_kind: "number" }, + { name: "round", description: "Rounds a numeric value to the nearest integer.", category: "math", reference_kind: "number" }, + { name: "coalesce", description: "Returns the first argument that is not null/undefined.", category: "math", reference_kind: "any" }, + { name: "cat", description: "Concatenates values as text.", category: "text", reference_kind: "any" }, + { name: "upper", description: "Converts text to uppercase.", category: "text", reference_kind: "text" }, + { name: "lower", description: "Converts text to lowercase.", category: "text", reference_kind: "text" }, + { name: "trim", description: "Removes leading/trailing whitespace from text.", category: "text", reference_kind: "text" }, + { name: "length", description: "Returns text length.", category: "text", reference_kind: "text" }, + { name: "left", description: "Returns left-most text characters (optional count, default 1).", category: "text", reference_kind: "text" }, + { name: "right", description: "Returns right-most text characters (optional count, default 1).", category: "text", reference_kind: "text" }, + { name: "year", description: "Extracts UTC year from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, + { name: "month", description: "Extracts UTC month (1-12) from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, + { name: "day", description: "Extracts UTC day-of-month from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, + { name: "hour", description: "Extracts UTC hour from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, + { name: "minute", description: "Extracts UTC minute from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, + { name: "second", description: "Extracts UTC second from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, + { name: "timestamp", description: "Converts a date/datetime value to Unix timestamp seconds.", category: "datetime", reference_kind: "datetime" }, + { name: "date_only", description: "Formats a date/datetime as YYYY-MM-DD (UTC).", category: "datetime", reference_kind: "datetime" }, + { name: "time_only", description: "Formats a date/datetime as HH:MM:SS (UTC).", category: "datetime", reference_kind: "datetime" }, + { + name: "days_between", + description: "Returns day difference between start and end date/datetime values.", + category: "date_diff", + reference_count: 2, + reference_kind: "datetime", + }, + { + name: "hours_between", + description: "Returns hour difference between start and end date/datetime values.", + category: "date_diff", + reference_count: 2, + reference_kind: "datetime", + }, + { + name: "hue_from_hex", + description: "Returns hue angle (0-360) for a hex color string.", + category: "color", + reference_kind: "text", + }, + { name: "today", description: "Returns current UTC date as YYYY-MM-DD.", category: "dynamic", insert_mode: "none" }, +]; + +export const FORMULA_HELPER_GROUP_ORDER: FormulaHelperCategory[] = ["math", "text", "datetime", "dynamic", "date_diff", "color"]; + +export const FORMULA_HELPER_GROUPS: FormulaHelperGroupDefinition[] = FORMULA_HELPER_GROUP_ORDER.map((key) => ({ + key, + helpers: FORMULA_HELPERS.filter((helper) => helper.category === key), +})); + +function coalesce(...values: unknown[]) { + return values.find((value) => value !== null && value !== undefined) ?? null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function asDate(value: unknown): Date { + if (value instanceof Date) { + return value; + } + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + if (!Number.isNaN(parsed.valueOf())) { + return parsed; + } + } + throw new Error("Value is not a date/datetime."); +} + +function pad(value: number): string { + return value.toString().padStart(2, "0"); +} + +function dateOnly(value: unknown): string { + const parsed = asDate(value); + return `${parsed.getUTCFullYear()}-${pad(parsed.getUTCMonth() + 1)}-${pad(parsed.getUTCDate())}`; +} + +function timeOnly(value: unknown): string { + const parsed = asDate(value); + return `${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}`; +} + +function daysBetween(start: unknown, end: unknown): number { + return (asDate(end).valueOf() - asDate(start).valueOf()) / 86400000; +} + +function hoursBetween(start: unknown, end: unknown): number { + return (asDate(end).valueOf() - asDate(start).valueOf()) / 3600000; +} + +function hueFromHex(value: unknown): number { + if (typeof value !== "string") { + throw new Error("hue_from_hex expects a color string."); + } + + let normalized = value.trim().replace(/^#/, ""); + if (normalized.length === 3) { + normalized = normalized + .split("") + .map((char) => char + char) + .join(""); + } + if (normalized.length !== 6) { + throw new Error("hue_from_hex expects a 3 or 6 digit hex color."); + } + + const red = parseInt(normalized.slice(0, 2), 16) / 255; + const green = parseInt(normalized.slice(2, 4), 16) / 255; + const blue = parseInt(normalized.slice(4, 6), 16) / 255; + + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + if (delta === 0) { + return 0; + } + + let hue = 0; + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + + const degrees = hue * 60; + return Math.round((((degrees % 360) + 360) % 360) * 1000) / 1000; +} + +function today(): string { + return dateOnly(new Date()); +} + +function normalizeJsonLogicArgs(rawValue: unknown): unknown[] { + if (Array.isArray(rawValue)) { + return rawValue; + } + return [rawValue]; +} + +function asNumber(value: unknown, operator: string): number { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new Error(`${operator} expects numeric values.`); + } + return parsed; +} + +function parseExtraValue(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function getReferenceValue(reference: string, scope: FormulaScope): unknown { + const parts = reference.split("."); + let current: unknown = scope; + + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + + const record = current as Record; + if (!(part in record)) { + return undefined; + } + + current = record[part]; + if (parts[index] === "extra") { + // Extra fields are still stored as JSON strings in API payloads, so derived formulas need to + // unwrap them lazily when a reference walks into the extra.* namespace. + current = parseExtraValue(current); + } + } + + return current; +} + +function collectFormulaReferencesFromJsonLogic(node: unknown, references: Set): void { + if (node === null || typeof node === "string" || typeof node === "number" || typeof node === "boolean") { + return; + } + if (Array.isArray(node)) { + node.forEach((value) => collectFormulaReferencesFromJsonLogic(value, references)); + return; + } + if (!isRecord(node)) { + return; + } + + const entries = Object.entries(node); + if (entries.length !== 1) { + return; + } + const [operator, rawArgs] = entries[0]; + const args = normalizeJsonLogicArgs(rawArgs); + + if (operator === "var") { + const reference = args[0]; + if (typeof reference === "string" && reference !== "") { + references.add(reference); + } + if (args.length > 1) { + collectFormulaReferencesFromJsonLogic(args[1], references); + } + return; + } + + args.forEach((arg) => collectFormulaReferencesFromJsonLogic(arg, references)); +} + +export function getFormulaReferencesFromJsonLogic(expressionJson: Record): string[] { + const references = new Set(); + collectFormulaReferencesFromJsonLogic(expressionJson, references); + return [...references]; +} + +export function getExtraFieldReferences(expressionJson?: Record | null): string[] { + const references = new Set(); + if (expressionJson) { + getFormulaReferencesFromJsonLogic(expressionJson).forEach((reference) => references.add(reference)); + } + const extraReferences = [...references] + .filter((reference) => reference.startsWith("extra.")) + .map((reference) => reference.slice("extra.".length)) + .filter((reference) => reference.length > 0); + return [...new Set(extraReferences)]; +} + +function lookupJsonLogicReference(reference: unknown, scope: FormulaScope, defaultValue: unknown): unknown { + const scopeRecord = scope as Record; + if (typeof reference === "number") { + return scopeRecord[String(reference)] ?? defaultValue; + } + if (typeof reference !== "string") { + throw new Error("JSON Logic var reference must be a string or integer."); + } + if (reference === "") { + return scope; + } + + const value = getReferenceValue(reference, scope); + return value === undefined ? defaultValue : value; +} + +function truthy(value: unknown): boolean { + return Boolean(value); +} + +export function evaluateFormulaJsonLogic(expressionJson: Record, scope: FormulaScope): unknown { + const evaluateNode = (node: unknown): unknown => { + if (node === null || typeof node === "string" || typeof node === "number" || typeof node === "boolean") { + return node; + } + if (Array.isArray(node)) { + return node.map((value) => evaluateNode(value)); + } + if (!isRecord(node)) { + throw new Error("JSON Logic expression contains unsupported value types."); + } + + const entries = Object.entries(node); + if (entries.length !== 1) { + throw new Error("JSON Logic expression objects must contain exactly one operator."); + } + + const [operator, rawArgs] = entries[0]; + const args = normalizeJsonLogicArgs(rawArgs); + + if (operator === "var") { + const reference = args[0] ?? ""; + const defaultValue = args.length > 1 ? evaluateNode(args[1]) : null; + return lookupJsonLogicReference(reference, scope, defaultValue); + } + + if (operator === "if") { + if (args.length < 2) { + throw new Error("JSON Logic if operator requires at least 2 arguments."); + } + for (let index = 0; index < args.length - 1; index += 2) { + if (truthy(evaluateNode(args[index]))) { + return evaluateNode(args[index + 1]); + } + } + if (args.length % 2 === 1) { + return evaluateNode(args[args.length - 1]); + } + return null; + } + + if (operator === "and") { + let result: unknown = true; + args.forEach((arg) => { + if (!truthy(result)) { + return; + } + result = evaluateNode(arg); + }); + return result; + } + + if (operator === "or") { + let result: unknown = false; + args.forEach((arg) => { + if (truthy(result)) { + return; + } + result = evaluateNode(arg); + }); + return result; + } + + if (operator === "!") { + if (args.length !== 1) { + throw new Error("JSON Logic ! operator requires one argument."); + } + return !truthy(evaluateNode(args[0])); + } + + const evaluatedArgs = args.map((arg) => evaluateNode(arg)); + + if (operator === "==") { + return evaluatedArgs[0] === evaluatedArgs[1]; + } + if (operator === "!=") { + return evaluatedArgs[0] !== evaluatedArgs[1]; + } + if (operator === "<") { + return (evaluatedArgs[0] as number) < (evaluatedArgs[1] as number); + } + if (operator === "<=") { + return (evaluatedArgs[0] as number) <= (evaluatedArgs[1] as number); + } + if (operator === ">") { + return (evaluatedArgs[0] as number) > (evaluatedArgs[1] as number); + } + if (operator === ">=") { + return (evaluatedArgs[0] as number) >= (evaluatedArgs[1] as number); + } + if (operator === "+") { + return evaluatedArgs.reduce((sum, value) => sum + asNumber(value, "+"), 0); + } + if (operator === "-") { + if (evaluatedArgs.length === 1) { + return -asNumber(evaluatedArgs[0], "-"); + } + return asNumber(evaluatedArgs[0], "-") - asNumber(evaluatedArgs[1], "-"); + } + if (operator === "*") { + return evaluatedArgs.reduce((product, value) => product * asNumber(value, "*"), 1); + } + if (operator === "/") { + if (evaluatedArgs.length !== 2) { + throw new Error("JSON Logic / operator requires two arguments."); + } + return asNumber(evaluatedArgs[0], "/") / asNumber(evaluatedArgs[1], "/"); + } + if (operator === "%") { + return asNumber(evaluatedArgs[0], "%") % asNumber(evaluatedArgs[1], "%"); + } + if (operator === "min") { + return Math.min(...evaluatedArgs.map((value) => asNumber(value, "min"))); + } + if (operator === "max") { + return Math.max(...evaluatedArgs.map((value) => asNumber(value, "max"))); + } + if (operator === "round") { + return Math.round(asNumber(evaluatedArgs[0], "round")); + } + if (operator === "floor") { + return Math.floor(asNumber(evaluatedArgs[0], "floor")); + } + if (operator === "ceil") { + return Math.ceil(asNumber(evaluatedArgs[0], "ceil")); + } + if (operator === "abs") { + return Math.abs(asNumber(evaluatedArgs[0], "abs")); + } + if (operator === "cat") { + return evaluatedArgs.map((value) => `${value ?? ""}`).join(""); + } + if (operator === "upper") { + return `${evaluatedArgs[0] ?? ""}`.toUpperCase(); + } + if (operator === "lower") { + return `${evaluatedArgs[0] ?? ""}`.toLowerCase(); + } + if (operator === "trim") { + return `${evaluatedArgs[0] ?? ""}`.trim(); + } + if (operator === "length") { + const value = evaluatedArgs[0]; + if (typeof value === "string" || Array.isArray(value)) { + return value.length; + } + if (isRecord(value)) { + return Object.keys(value).length; + } + throw new Error("length expects string, array, or object."); + } + if (operator === "replace") { + return `${evaluatedArgs[0] ?? ""}`.replace(`${evaluatedArgs[1] ?? ""}`, `${evaluatedArgs[2] ?? ""}`); + } + if (operator === "left") { + const value = `${evaluatedArgs[0] ?? ""}`; + const count = evaluatedArgs.length > 1 ? asNumber(evaluatedArgs[1], "left") : 1; + const length = Math.max(0, Math.floor(count)); + return value.slice(0, length); + } + if (operator === "right") { + const value = `${evaluatedArgs[0] ?? ""}`; + const count = evaluatedArgs.length > 1 ? asNumber(evaluatedArgs[1], "right") : 1; + const length = Math.max(0, Math.floor(count)); + if (length === 0) { + return ""; + } + return value.slice(-length); + } + if (operator === "coalesce") { + return coalesce(...evaluatedArgs); + } + if (operator === "today") { + return today(); + } + if (operator === "year") { + return asDate(evaluatedArgs[0]).getUTCFullYear(); + } + if (operator === "month") { + return asDate(evaluatedArgs[0]).getUTCMonth() + 1; + } + if (operator === "day") { + return asDate(evaluatedArgs[0]).getUTCDate(); + } + if (operator === "hour") { + return asDate(evaluatedArgs[0]).getUTCHours(); + } + if (operator === "minute") { + return asDate(evaluatedArgs[0]).getUTCMinutes(); + } + if (operator === "second") { + return asDate(evaluatedArgs[0]).getUTCSeconds(); + } + if (operator === "timestamp") { + return asDate(evaluatedArgs[0]).valueOf() / 1000; + } + if (operator === "date_only") { + return dateOnly(evaluatedArgs[0]); + } + if (operator === "time_only") { + return timeOnly(evaluatedArgs[0]); + } + if (operator === "days_between") { + return daysBetween(evaluatedArgs[0], evaluatedArgs[1]); + } + if (operator === "hours_between") { + return hoursBetween(evaluatedArgs[0], evaluatedArgs[1]); + } + if (operator === "hue_from_hex") { + return hueFromHex(evaluatedArgs[0]); + } + + throw new Error(`JSON Logic operator '${operator}' is not implemented.`); + }; + + return evaluateNode(expressionJson); +} + +export function getTemplateFormulaFields(fields: DerivedField[] | undefined): DerivedField[] { + return (fields || []).filter((field) => field.surfaces.includes(ComplexFieldSurface.template)); +} + +export function getFormulaFieldsForSurface( + fields: DerivedField[] | undefined, + surface: ComplexFieldSurface, +): DerivedField[] { + return (fields || []).filter((field) => field.surfaces.includes(surface)); +} + +export function buildFormulaValues(scope: FormulaScope, fields: DerivedField[]): Record { + const values: Record = {}; + fields.forEach((field) => { + try { + if (field.expression_json) { + values[field.key] = evaluateFormulaJsonLogic(field.expression_json, scope); + } + } catch { + // Failed evaluations stay hidden so one invalid formula does not break show/list/template + // rendering for the rest of the entity payload. + } + }); + return values; +} + +export function formatFormulaValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return `${value}`; + } + return JSON.stringify(value); +} diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index 7cde38c05..9b3fe6f89 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -19,6 +19,15 @@ export enum EntityType { spool = "spool", } +export enum ComplexFieldSurface { + show = "show", + edit = "edit", + list = "list", + template = "template", + action = "action", + derived = "derived", +} + export interface FieldParameters { name: string; order: number; @@ -34,6 +43,31 @@ export interface Field extends FieldParameters { entity_type: EntityType; } +export enum DerivedFieldType { + number = "number", + text = "text", +} + +export interface DerivedFieldParameters { + name: string; + description?: string; + result_type: DerivedFieldType; + expression_json: Record; + surfaces: string[]; + allow_list_column_toggle: boolean; + include_in_api: boolean; +} + +export interface DerivedField extends DerivedFieldParameters { + key: string; + entity_type: EntityType; +} + +export interface DerivedFieldPreview { + result: string | number | boolean | null; + references: string[]; +} + export function useGetFields(entity_type: EntityType) { return useQuery({ queryKey: ["fields", entity_type], @@ -134,3 +168,90 @@ export function useDeleteField(entity_type: EntityType) { }, }); } + +export function useGetDerivedFields(entity_type: EntityType) { + return useQuery({ + queryKey: ["derivedFields", entity_type], + queryFn: async () => { + const response = await fetch(`${getAPIURL()}/field/derived/${entity_type}`); + return response.json(); + }, + }); +} + +export function useSetDerivedField(entity_type: EntityType) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ key, params }) => { + const response = await fetch(`${getAPIURL()}/field/derived/${entity_type}/${key}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error((await response.json()).message); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["derivedFields", entity_type], + }); + }, + }); +} + +export function useDeleteDerivedField(entity_type: EntityType) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (key) => { + const response = await fetch(`${getAPIURL()}/field/derived/${entity_type}/${key}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error((await response.json()).message); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["derivedFields", entity_type], + }); + }, + }); +} + +export function usePreviewDerivedField(entity_type: EntityType) { + return useMutation< + DerivedFieldPreview, + unknown, + { expression_json: Record; sample_values: Record } + >({ + mutationFn: async ({ expression_json, sample_values }) => { + const response = await fetch(`${getAPIURL()}/field/derived/${entity_type}/preview`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + expression_json, + sample_values, + }), + }); + + if (!response.ok) { + throw new Error((await response.json()).message); + } + + return response.json(); + }, + }); +} diff --git a/scripts/json_logic_parity.py b/scripts/json_logic_parity.py new file mode 100644 index 000000000..0bad6b07f --- /dev/null +++ b/scripts/json_logic_parity.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Run JSON Logic parity fixtures against a selected Python runtime.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +TIME_RE = re.compile(r"^\d{2}:\d{2}:\d{2}$") + + +@dataclass +class FixtureResult: + fixture_id: str + status: str + detail: str = "" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--fixtures", + type=Path, + default=Path("tests_integration/tests/fields/json_logic_parity_fixtures.json"), + help="Path to JSON fixtures file.", + ) + parser.add_argument( + "--engine", + choices=["json_logic_py"], + default="json_logic_py", + help="Python evaluator runtime to use.", + ) + return parser.parse_args() + + +def _as_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + normalized = value.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + dt = datetime.fromisoformat(normalized) + else: + raise ValueError(f"Unsupported datetime input: {value!r}") + + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _date_only(value: Any) -> str: + return _as_datetime(value).date().isoformat() + + +def _time_only(value: Any) -> str: + return _as_datetime(value).time().replace(microsecond=0).isoformat() + + +def _days_between(start: Any, end: Any) -> float: + return (_as_datetime(end) - _as_datetime(start)).total_seconds() / 86400 + + +def _hours_between(start: Any, end: Any) -> float: + return (_as_datetime(end) - _as_datetime(start)).total_seconds() / 3600 + + +def _hue_from_hex(value: Any) -> float: + if not isinstance(value, str): + raise ValueError("hue_from_hex expects a hex color string.") + + normalized = value.strip().lstrip("#") + if len(normalized) == 3: + normalized = "".join(char * 2 for char in normalized) + if len(normalized) != 6: + raise ValueError("hue_from_hex expects 3 or 6 hex digits.") + + red = int(normalized[0:2], 16) / 255 + green = int(normalized[2:4], 16) / 255 + blue = int(normalized[4:6], 16) / 255 + + max_value = max(red, green, blue) + min_value = min(red, green, blue) + delta = max_value - min_value + if delta == 0: + return 0.0 + + if max_value == red: + hue = ((green - blue) / delta) % 6 + elif max_value == green: + hue = (blue - red) / delta + 2 + else: + hue = (red - green) / delta + 4 + + return round((hue * 60) % 360, 3) + + +def _coalesce(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + +def _replace(value: Any, old: Any, new: Any) -> str: + return str(value).replace(str(old), str(new)) + + +def _to_number(value: Any) -> float: + if isinstance(value, bool): + return float(int(value)) + if isinstance(value, (int, float)): + return float(value) + return float(str(value)) + + +def _install_custom_operations(operations: dict[str, Any]) -> None: + # Keep helper/operator names aligned with the planned Formula Extra Fields vocabulary so this + # harness reflects the real target behavior instead of raw library defaults. + operations.update( + { + "abs": abs, + "round": round, + "floor": math.floor, + "ceil": math.ceil, + "coalesce": _coalesce, + "today": lambda: datetime.now(timezone.utc).date().isoformat(), + "date_only": _date_only, + "time_only": _time_only, + "days_between": _days_between, + "hours_between": _hours_between, + "hue_from_hex": _hue_from_hex, + "upper": lambda value: str(value).upper(), + "lower": lambda value: str(value).lower(), + "trim": lambda value: str(value).strip(), + "length": lambda value: len(value), + "replace": _replace, + "timestamp": lambda value: _as_datetime(value).timestamp(), + "to_number": _to_number, + }, + ) + + +def _load_engine(engine_name: str) -> Any: + if engine_name != "json_logic_py": + raise RuntimeError(f"Unsupported engine: {engine_name}") + + try: + from json_logic import jsonLogic, operations # type: ignore[import-not-found] + except ModuleNotFoundError as exc: + raise RuntimeError( + "json-logic-py is not installed. Install candidate runtime first, e.g. " + "`pip install json-logic-py`.", + ) from exc + + _install_custom_operations(operations) + return jsonLogic + + +def _validate_result_type(value: Any, result_type: str) -> None: + if result_type == "number": + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("result_type_mismatch:number") + return + if result_type == "text": + if not isinstance(value, str): + raise ValueError("result_type_mismatch:text") + return + if result_type == "boolean": + if not isinstance(value, bool): + raise ValueError("result_type_mismatch:boolean") + return + if result_type == "date": + if not isinstance(value, str) or DATE_RE.match(value) is None: + raise ValueError("result_type_mismatch:date") + return + if result_type == "time": + if not isinstance(value, str) or TIME_RE.match(value) is None: + raise ValueError("result_type_mismatch:time") + return + if result_type == "datetime": + if not isinstance(value, str): + raise ValueError("result_type_mismatch:datetime") + _as_datetime(value) + return + raise ValueError(f"Unsupported result type: {result_type}") + + +def _classify_error(exc: Exception) -> str: + message = str(exc) + if "Unrecognized operation" in message: + return "operator_not_allowed" + if "result_type_mismatch" in message: + return "result_type_mismatch" + if any(token in message for token in ["NoneType", "missing", "float()", "unsupported operand"]): + return "missing_reference" + return "other" + + +def _compare_values(actual: Any, expected: Any) -> bool: + if isinstance(actual, (float, int)) and isinstance(expected, (float, int)): + return math.isclose(float(actual), float(expected), rel_tol=1e-9, abs_tol=1e-9) + return actual == expected + + +def run_fixtures(fixtures_path: Path, engine_name: str) -> list[FixtureResult]: + evaluator = _load_engine(engine_name) + fixtures = json.loads(fixtures_path.read_text(encoding="utf-8")) + results: list[FixtureResult] = [] + + for fixture in fixtures: + fixture_id = fixture["id"] + result_type = fixture["result_type"] + expression_json = fixture["expression_json"] + scope = fixture.get("scope", {}) + expected_error = fixture.get("expect_error") + expected_value = fixture.get("expected") + expected_shape = fixture.get("expected_shape") + + try: + actual = evaluator(expression_json, scope) + _validate_result_type(actual, result_type) + if expected_error: + results.append( + FixtureResult( + fixture_id=fixture_id, + status="fail", + detail=f"expected error `{expected_error}` but evaluation succeeded with `{actual!r}`", + ), + ) + continue + if expected_shape == "yyyy-mm-dd": + if not isinstance(actual, str) or DATE_RE.match(actual) is None: + results.append( + FixtureResult( + fixture_id=fixture_id, + status="fail", + detail=f"expected date shape yyyy-mm-dd but got `{actual!r}`", + ), + ) + continue + elif expected_value is not None and not _compare_values(actual, expected_value): + results.append( + FixtureResult( + fixture_id=fixture_id, + status="fail", + detail=f"expected `{expected_value!r}` but got `{actual!r}`", + ), + ) + continue + results.append(FixtureResult(fixture_id=fixture_id, status="pass")) + except Exception as exc: # noqa: BLE001 + if not expected_error: + results.append(FixtureResult(fixture_id=fixture_id, status="fail", detail=f"unexpected error: {exc}")) + continue + actual_error = _classify_error(exc) + if actual_error != expected_error: + results.append( + FixtureResult( + fixture_id=fixture_id, + status="fail", + detail=f"expected error `{expected_error}` but got `{actual_error}` ({exc})", + ), + ) + continue + results.append(FixtureResult(fixture_id=fixture_id, status="pass")) + + return results + + +def main() -> int: + args = parse_args() + try: + results = run_fixtures(args.fixtures, args.engine) + except RuntimeError as exc: + print(f"ERROR: {exc}") # noqa: T201 + return 2 + + passed = [result for result in results if result.status == "pass"] + failed = [result for result in results if result.status != "pass"] + + print(f"Engine: {args.engine}") # noqa: T201 + print(f"Fixtures: {args.fixtures}") # noqa: T201 + print(f"Passed: {len(passed)} / {len(results)}") # noqa: T201 + if failed: + print("Failures:") # noqa: T201 + for item in failed: + print(f"- {item.fixture_id}: {item.detail}") # noqa: T201 + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pr-preflight.sh b/scripts/pr-preflight.sh new file mode 100755 index 000000000..703c7ad8d --- /dev/null +++ b/scripts/pr-preflight.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/pr-preflight.sh [options] + +Examples: + scripts/pr-preflight.sh 874 --expected-worktree /private/tmp/spoolman_pr874_runtime_iQoS --expected-branch feat/complex-fields-framework --strict + scripts/pr-preflight.sh 874 --expected-worktree /private/tmp/spoolman_pr874_runtime_iQoS --expected-branch feat/complex-fields-framework --strict --require-container --require-url + scripts/pr-preflight.sh 876 --expected-branch tmp/pr876-template-filters --strict + +Options: + --expected-worktree Exact worktree path required for strict checks. + --expected-branch Exact branch name required for strict checks. + --require-container Require spoolman_pr_8 to be running. + --require-url Require localhost:8 to respond with HTTP status. + --strict Exit non-zero when any mismatch is found. +EOF +} + +if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if ! [[ "$1" =~ ^[0-9]+$ ]]; then + echo "ERROR: first argument must be a numeric PR id." >&2 + usage + exit 1 +fi + +PR="$1" +shift + +EXPECTED_WORKTREE="" +EXPECTED_BRANCH="" +REQUIRE_CONTAINER=0 +REQUIRE_URL=0 +STRICT=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --expected-worktree) + EXPECTED_WORKTREE="${2:-}" + shift 2 + ;; + --expected-branch) + EXPECTED_BRANCH="${2:-}" + shift 2 + ;; + --require-container) + REQUIRE_CONTAINER=1 + shift + ;; + --require-url) + REQUIRE_URL=1 + shift + ;; + --strict) + STRICT=1 + shift + ;; + *) + echo "ERROR: unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + echo "ERROR: not inside a git repository." >&2 + exit 1 +fi + +CURRENT_PWD="$(pwd -P)" +REPO_ROOT="$(git rev-parse --show-toplevel)" +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +HEAD_LINE="$(git log --oneline -n 1)" + +PORT="8${PR}" +CONTAINER="spoolman_pr${PR}_${PORT}" +DB_PATH="/tmp/spoolman_pr_${PR}_data" +URL="http://localhost:${PORT}" + +echo "PR : #${PR}" +echo "Repo Root : ${REPO_ROOT}" +echo "Worktree : ${CURRENT_PWD}" +echo "Branch : ${CURRENT_BRANCH}" +echo "HEAD : ${HEAD_LINE}" +echo "Container : ${CONTAINER}" +echo "Port : ${PORT}" +echo "DB Mount : ${DB_PATH}" +echo "URL : ${URL}" +echo "Strict Mode : ${STRICT}" + +ERRORS=0 +WARNINGS=0 + +if [[ -n "${EXPECTED_WORKTREE}" && "${CURRENT_PWD}" != "${EXPECTED_WORKTREE}" ]]; then + echo "MISMATCH: worktree '${CURRENT_PWD}' != expected '${EXPECTED_WORKTREE}'" >&2 + ERRORS=1 +fi + +if [[ -n "${EXPECTED_BRANCH}" && "${CURRENT_BRANCH}" != "${EXPECTED_BRANCH}" ]]; then + echo "MISMATCH: branch '${CURRENT_BRANCH}' != expected '${EXPECTED_BRANCH}'" >&2 + ERRORS=1 +fi + +if [[ "${CURRENT_BRANCH}" == "HEAD" ]]; then + echo "MISMATCH: detached HEAD detected. Switch to the PR branch before editing." >&2 + ERRORS=1 +fi + +# If no explicit expected values are provided, still surface a context warning when +# neither path nor branch appears to include the PR id. +if [[ -z "${EXPECTED_WORKTREE}" && -z "${EXPECTED_BRANCH}" ]]; then + if ! [[ "${CURRENT_PWD}" =~ ${PR} || "${CURRENT_BRANCH}" =~ ${PR} ]]; then + echo "WARNING: PR id '${PR}' not found in current worktree path or branch name." >&2 + WARNINGS=1 + fi +fi + +if command -v docker >/dev/null 2>&1; then + echo "Docker :" + CONTAINER_LINE="$(docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' | awk -v name="${CONTAINER}" '$1 == name {print $0}')" + if [[ -n "${CONTAINER_LINE}" ]]; then + echo " ${CONTAINER_LINE}" + else + echo " (not running)" + if [[ "${REQUIRE_CONTAINER}" -eq 1 ]]; then + echo "MISMATCH: required container '${CONTAINER}' is not running." >&2 + ERRORS=1 + fi + fi +else + echo "Docker : not found" + if [[ "${REQUIRE_CONTAINER}" -eq 1 ]]; then + echo "MISMATCH: --require-container specified but docker is not available." >&2 + ERRORS=1 + fi +fi + +if [[ "${REQUIRE_URL}" -eq 1 ]]; then + if command -v curl >/dev/null 2>&1; then + HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' "${URL}" || true)" + echo "URL Probe : ${HTTP_CODE}" + if [[ "${HTTP_CODE}" == "000" ]]; then + echo "MISMATCH: URL '${URL}' is not reachable." >&2 + ERRORS=1 + fi + else + echo "URL Probe : curl not found" + echo "MISMATCH: --require-url specified but curl is not available." >&2 + ERRORS=1 + fi +fi + +if [[ "${ERRORS}" -eq 0 ]]; then + if [[ "${WARNINGS}" -eq 0 ]]; then + echo "RESULT : PASS" + else + echo "RESULT : PASS (with warnings)" + fi +else + echo "RESULT : FAIL" +fi + +if [[ "${STRICT}" -eq 1 && "${ERRORS}" -ne 0 ]]; then + echo "ERROR: strict preflight failed." >&2 + exit 2 +fi diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py index 36e63d845..929fbb885 100644 --- a/spoolman/api/v1/field.py +++ b/spoolman/api/v1/field.py @@ -9,6 +9,16 @@ from spoolman.api.v1.models import Message from spoolman.database.database import get_db_session +from spoolman.derived_fields import ( + DerivedFieldDefinition, + DerivedFieldParameters, + DerivedFieldPreviewRequest, + DerivedFieldPreviewResponse, + add_or_update_derived_field, + delete_derived_field, + get_derived_fields, + preview_derived_payload, +) from spoolman.exceptions import ItemNotFoundError from spoolman.extra_fields import ( EntityType, @@ -29,6 +39,101 @@ logger = logging.getLogger(__name__) +@router.get( + "/derived/{entity_type}", + name="Get derived fields", + description="Get all user-defined derived fields for a specific entity type.", + response_model_exclude_none=True, +) +async def get_derived( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this derived field is for")], +) -> list[DerivedFieldDefinition]: + return await get_derived_fields(db, entity_type) + + +@router.post( + "/derived/{entity_type}/preview", + name="Preview derived field", + description="Validate and preview a derived field JSON Logic expression with sample values.", + response_model_exclude_none=True, + response_model=DerivedFieldPreviewResponse, + responses={400: {"model": Message}}, +) +async def preview_derived( + entity_type: Annotated[EntityType, Path(description="Entity type this derived field is for")], + body: DerivedFieldPreviewRequest, +) -> DerivedFieldPreviewResponse | JSONResponse: + # The route stays entity-scoped for UI symmetry, but preview validation is intentionally pure: + # it only checks expression syntax/helpers against sample values and does not read entity data. + del entity_type + try: + return preview_derived_payload( + expression_json=body.expression_json, + sample_values=body.sample_values, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=Message(message=str(exc)).dict()) + + +@router.post( + "/derived/{entity_type}/{key}", + name="Add or update derived field", + description=( + "Add or update a derived field for a specific entity type. " + "Returns the full list of derived fields for the entity type." + ), + response_model_exclude_none=True, + response_model=list[DerivedFieldDefinition], + responses={400: {"model": Message}}, +) +async def update_derived( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this derived field is for")], + key: Annotated[str, Path(min_length=1, max_length=64, pattern="^[a-z0-9_]+$")], + body: DerivedFieldParameters, +) -> list[DerivedFieldDefinition] | JSONResponse: + dict_body = body.model_dump() + dict_body["key"] = key + dict_body["entity_type"] = entity_type + body_with_key = DerivedFieldDefinition.model_validate(dict_body) + + try: + await add_or_update_derived_field(db, entity_type, body_with_key) + except ValueError as exc: + return JSONResponse(status_code=400, content=Message(message=str(exc)).dict()) + + return await get_derived_fields(db, entity_type) + + +@router.delete( + "/derived/{entity_type}/{key}", + name="Delete derived field", + description=( + "Delete a derived field for a specific entity type. Returns the full list of derived fields for the entity type." + ), + response_model_exclude_none=True, + response_model=list[DerivedFieldDefinition], + responses={404: {"model": Message}}, +) +async def delete_derived( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this derived field is for")], + key: Annotated[str, Path(min_length=1, max_length=64, pattern="^[a-z0-9_]+$")], +) -> list[DerivedFieldDefinition] | JSONResponse: + try: + await delete_derived_field(db, entity_type, key) + except ItemNotFoundError: + return JSONResponse( + status_code=404, + content=Message( + message=f"Derived field with key {key} does not exist for entity type {entity_type.name}", + ).dict(), + ) + + return await get_derived_fields(db, entity_type) + + @router.get( "/{entity_type}", name="Get extra fields", diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..1b595a20b 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -14,6 +14,12 @@ from spoolman.database import filament from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder +from spoolman.derived_fields import ( + build_formula_scope, + evaluate_derived_fields_for_scope, + get_derived_fields_for_surface, + resolve_include_derived_in_api, +) from spoolman.exceptions import ItemDeleteError from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict from spoolman.ws import websocket_manager @@ -318,6 +324,16 @@ async def find( int | None, Query(title="Limit", description="Maximum number of items in the response."), ] = None, + include_derived: Annotated[ + bool | None, + Query( + title="Include Derived", + description=( + "Include formula extra fields under payload.derived. " + "If omitted, the api_include_derived_fields setting is used." + ), + ), + ] = None, offset: Annotated[int, Query(title="Offset", description="Offset in the full result set if a limit is set.")] = 0, ) -> JSONResponse: sort_by: dict[str, SortOrder] = {} @@ -355,11 +371,32 @@ async def find( limit=limit, offset=offset, ) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + payload: list[Filament] = [] + # List endpoints should only evaluate fields configured for list/table surfaces. + derived_fields = ( + await get_derived_fields_for_surface(db, EntityType.filament, "list", api_enabled_only=True) + if include_derived_resolved + else [] + ) + + for db_item in db_items: + filament_payload = Filament.from_db(db_item) + if include_derived_resolved and derived_fields: + scope = build_formula_scope(filament_payload.model_dump(exclude_none=True)) + derived_values = evaluate_derived_fields_for_scope( + derived_fields=derived_fields, + scope=scope, + entity_type=EntityType.filament, + entity_id=filament_payload.id, + ) + filament_payload = filament_payload.model_copy(update={"derived": derived_values or None}) + payload.append(filament_payload) # Set x-total-count header for pagination return JSONResponse( content=jsonable_encoder( - (Filament.from_db(db_item) for db_item in db_items), + payload, exclude_none=True, ), headers={"x-total-count": str(total_count)}, @@ -397,9 +434,38 @@ async def notify_any( async def get( db: Annotated[AsyncSession, Depends(get_db_session)], filament_id: int, + include_derived: Annotated[ + bool | None, + Query( + title="Include Derived", + description=( + "Include formula extra fields under payload.derived. " + "If omitted, the api_include_derived_fields setting is used." + ), + ), + ] = None, ) -> Filament: db_item = await filament.get_by_id(db, filament_id) - return Filament.from_db(db_item) + filament_payload = Filament.from_db(db_item) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + if include_derived_resolved: + # Detail endpoints should evaluate show-surface formulas only. + derived_fields = await get_derived_fields_for_surface( + db, + EntityType.filament, + "show", + api_enabled_only=True, + ) + if derived_fields: + scope = build_formula_scope(filament_payload.model_dump(exclude_none=True)) + derived_values = evaluate_derived_fields_for_scope( + derived_fields=derived_fields, + scope=scope, + entity_type=EntityType.filament, + entity_id=filament_payload.id, + ) + filament_payload = filament_payload.model_copy(update={"derived": derived_values or None}) + return filament_payload @router.websocket( diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index a24d79f7c..9dba64f42 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from enum import Enum -from typing import Annotated, Literal +from typing import Any, Annotated, Literal from pydantic import BaseModel, Field, PlainSerializer @@ -78,9 +78,13 @@ class Vendor(BaseModel): "Query the /fields endpoint for more details about the fields." ), ) + derived: dict[str, Any] | None = Field( + default=None, + description="Optional derived values computed from formula extra fields.", + ) @staticmethod - def from_db(item: models.Vendor) -> "Vendor": + def from_db(item: models.Vendor, derived: dict[str, Any] | None = None) -> "Vendor": """Create a new Pydantic vendor object from a database vendor object.""" return Vendor( id=item.id, @@ -90,6 +94,7 @@ def from_db(item: models.Vendor) -> "Vendor": empty_spool_weight=item.empty_spool_weight, external_id=item.external_id, extra={field.key: field.value for field in item.extra}, + derived=derived, ) @@ -197,9 +202,13 @@ class Filament(BaseModel): "Query the /fields endpoint for more details about the fields." ), ) + derived: dict[str, Any] | None = Field( + default=None, + description="Optional derived values computed from formula extra fields.", + ) @staticmethod - def from_db(item: models.Filament) -> "Filament": + def from_db(item: models.Filament, derived: dict[str, Any] | None = None) -> "Filament": """Create a new Pydantic filament object from a database filament object.""" return Filament( id=item.id, @@ -223,6 +232,7 @@ def from_db(item: models.Filament) -> "Filament": ), external_id=item.external_id, extra={field.key: field.value for field in item.extra}, + derived=derived, ) @@ -309,9 +319,13 @@ class Spool(BaseModel): "Query the /fields endpoint for more details about the fields." ), ) + derived: dict[str, Any] | None = Field( + default=None, + description="Optional derived values computed from formula extra fields.", + ) @staticmethod - def from_db(item: models.Spool) -> "Spool": + def from_db(item: models.Spool, derived: dict[str, Any] | None = None) -> "Spool": """Create a new Pydantic spool object from a database spool object.""" filament = Filament.from_db(item.filament) @@ -357,6 +371,7 @@ def from_db(item: models.Spool) -> "Spool": comment=item.comment, archived=item.archived if item.archived is not None else False, extra={field.key: field.value for field in item.extra}, + derived=derived, ) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..5f17224a2 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -15,6 +15,12 @@ from spoolman.database import spool from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder +from spoolman.derived_fields import ( + build_formula_scope, + evaluate_derived_fields_for_scope, + get_derived_fields_for_surface, + resolve_include_derived_in_api, +) from spoolman.exceptions import ItemCreateError, SpoolMeasureError from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict from spoolman.ws import websocket_manager @@ -265,6 +271,16 @@ async def find( int | None, Query(title="Limit", description="Maximum number of items in the response."), ] = None, + include_derived: Annotated[ + bool | None, + Query( + title="Include Derived", + description=( + "Include formula extra fields under payload.derived. " + "If omitted, the api_include_derived_fields setting is used." + ), + ), + ] = None, offset: Annotated[int, Query(title="Offset", description="Offset in the full result set if a limit is set.")] = 0, ) -> JSONResponse: sort_by: dict[str, SortOrder] = {} @@ -299,11 +315,32 @@ async def find( limit=limit, offset=offset, ) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + payload: list[Spool] = [] + # List endpoints should only evaluate fields configured for list/table surfaces. + derived_fields = ( + await get_derived_fields_for_surface(db, EntityType.spool, "list", api_enabled_only=True) + if include_derived_resolved + else [] + ) + + for db_item in db_items: + spool_payload = Spool.from_db(db_item) + if include_derived_resolved and derived_fields: + scope = build_formula_scope(spool_payload.model_dump(exclude_none=True)) + derived_values = evaluate_derived_fields_for_scope( + derived_fields=derived_fields, + scope=scope, + entity_type=EntityType.spool, + entity_id=spool_payload.id, + ) + spool_payload = spool_payload.model_copy(update={"derived": derived_values or None}) + payload.append(spool_payload) # Set x-total-count header for pagination return JSONResponse( content=jsonable_encoder( - (Spool.from_db(db_item) for db_item in db_items), + payload, exclude_none=True, ), headers={"x-total-count": str(total_count)}, @@ -341,9 +378,33 @@ async def notify_any( async def get( db: Annotated[AsyncSession, Depends(get_db_session)], spool_id: int, + include_derived: Annotated[ + bool | None, + Query( + title="Include Derived", + description=( + "Include formula extra fields under payload.derived. " + "If omitted, the api_include_derived_fields setting is used." + ), + ), + ] = None, ) -> Spool: db_item = await spool.get_by_id(db, spool_id) - return Spool.from_db(db_item) + spool_payload = Spool.from_db(db_item) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + if include_derived_resolved: + # Detail endpoints should evaluate show-surface formulas only. + derived_fields = await get_derived_fields_for_surface(db, EntityType.spool, "show", api_enabled_only=True) + if derived_fields: + scope = build_formula_scope(spool_payload.model_dump(exclude_none=True)) + derived_values = evaluate_derived_fields_for_scope( + derived_fields=derived_fields, + scope=scope, + entity_type=EntityType.spool, + entity_id=spool_payload.id, + ) + spool_payload = spool_payload.model_copy(update={"derived": derived_values or None}) + return spool_payload @router.websocket( diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index 9216fba30..cbc4ab363 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -13,6 +13,12 @@ from spoolman.database import vendor from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder +from spoolman.derived_fields import ( + build_formula_scope, + evaluate_derived_fields_for_scope, + get_derived_fields_for_surface, + resolve_include_derived_in_api, +) from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict from spoolman.ws import websocket_manager @@ -116,6 +122,16 @@ async def find( int | None, Query(title="Limit", description="Maximum number of items in the response."), ] = None, + include_derived: Annotated[ + bool | None, + Query( + title="Include Derived", + description=( + "Include formula extra fields under payload.derived. " + "If omitted, the api_include_derived_fields setting is used." + ), + ), + ] = None, offset: Annotated[int, Query(title="Offset", description="Offset in the full result set if a limit is set.")] = 0, ) -> JSONResponse: sort_by: dict[str, SortOrder] = {} @@ -132,10 +148,31 @@ async def find( limit=limit, offset=offset, ) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + payload: list[Vendor] = [] + # List endpoints should only evaluate fields configured for list/table surfaces. + derived_fields = ( + await get_derived_fields_for_surface(db, EntityType.vendor, "list", api_enabled_only=True) + if include_derived_resolved + else [] + ) + + for db_item in db_items: + vendor_payload = Vendor.from_db(db_item) + if include_derived_resolved and derived_fields: + scope = build_formula_scope(vendor_payload.model_dump(exclude_none=True)) + derived_values = evaluate_derived_fields_for_scope( + derived_fields=derived_fields, + scope=scope, + entity_type=EntityType.vendor, + entity_id=vendor_payload.id, + ) + vendor_payload = vendor_payload.model_copy(update={"derived": derived_values or None}) + payload.append(vendor_payload) # Set x-total-count header for pagination return JSONResponse( content=jsonable_encoder( - (Vendor.from_db(db_item) for db_item in db_items), + payload, exclude_none=True, ), headers={"x-total-count": str(total_count)}, @@ -173,9 +210,33 @@ async def notify_any( async def get( db: Annotated[AsyncSession, Depends(get_db_session)], vendor_id: int, + include_derived: Annotated[ + bool | None, + Query( + title="Include Derived", + description=( + "Include formula extra fields under payload.derived. " + "If omitted, the api_include_derived_fields setting is used." + ), + ), + ] = None, ) -> Vendor: db_item = await vendor.get_by_id(db, vendor_id) - return Vendor.from_db(db_item) + vendor_payload = Vendor.from_db(db_item) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + if include_derived_resolved: + # Detail endpoints should evaluate show-surface formulas only. + derived_fields = await get_derived_fields_for_surface(db, EntityType.vendor, "show", api_enabled_only=True) + if derived_fields: + scope = build_formula_scope(vendor_payload.model_dump(exclude_none=True)) + derived_values = evaluate_derived_fields_for_scope( + derived_fields=derived_fields, + scope=scope, + entity_type=EntityType.vendor, + entity_id=vendor_payload.id, + ) + vendor_payload = vendor_payload.model_copy(update={"derived": derived_values or None}) + return vendor_payload @router.websocket( diff --git a/spoolman/derived_fields.py b/spoolman/derived_fields.py new file mode 100644 index 000000000..419224c9e --- /dev/null +++ b/spoolman/derived_fields.py @@ -0,0 +1,613 @@ +"""User-defined derived fields with safe expression evaluation.""" + +import colorsys +import json +import logging +import math +from datetime import date, datetime, time, timezone +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database import setting as db_setting +from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_fields import EntityType +from spoolman.settings import parse_setting + +logger = logging.getLogger(__name__) + +class DerivedFieldType(Enum): + """Supported output types for a derived field.""" + + number = "number" + text = "text" + + +class DerivedFieldDefinition(BaseModel): + """Stored user-defined derived field.""" + + key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) + entity_type: EntityType = Field(description="Entity type this derived field is for") + name: str = Field(description="Display name", min_length=1, max_length=128) + description: str | None = Field(default=None, description="Optional description", max_length=512) + result_type: DerivedFieldType = Field(description="Expected result type") + expression_json: dict[str, Any] = Field(description="Derived expression in JSON Logic format") + surfaces: list[str] = Field(default_factory=list, description="Where this derived field should appear") + allow_list_column_toggle: bool = Field( + default=False, + description="Whether list-surface fields can be hidden or shown from the column picker", + ) + include_in_api: bool = Field( + default=False, + description="Whether this formula field can be exposed in API derived payloads", + ) + + +class DerivedFieldParameters(BaseModel): + """Editable parameters for a derived field.""" + + name: str = Field(description="Display name", min_length=1, max_length=128) + description: str | None = Field(default=None, description="Optional description", max_length=512) + result_type: DerivedFieldType = Field(description="Expected result type") + expression_json: dict[str, Any] = Field(description="Derived expression in JSON Logic format") + surfaces: list[str] = Field(default_factory=list, description="Where this derived field should appear") + allow_list_column_toggle: bool = Field( + default=False, + description="Whether list-surface fields can be hidden or shown from the column picker", + ) + include_in_api: bool = Field( + default=False, + description="Whether this formula field can be exposed in API derived payloads", + ) + + +class DerivedFieldPreviewRequest(BaseModel): + """Preview request for evaluating a derived field expression.""" + + expression_json: dict[str, Any] = Field(description="Derived expression in JSON Logic format") + sample_values: dict[str, Any] = Field(default_factory=dict, description="Sample values keyed by field reference") + + +class DerivedFieldPreviewResponse(BaseModel): + """Preview result for a derived field expression.""" + + result: str | float | int | bool | None = Field(description="Preview result") + references: list[str] = Field(default_factory=list, description="Field references used by the expression") + + +_derived_field_cache: dict[EntityType, list[DerivedFieldDefinition]] = {} + + +def _as_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, time.min) + if isinstance(value, str): + normalized = value.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + return datetime.fromisoformat(normalized) + raise ValueError(f"Value {value!r} is not a datetime-compatible input.") + + +def _coalesce(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + +def _date_only(value: Any) -> str: + return _as_datetime(value).date().isoformat() + + +def _time_only(value: Any) -> str: + return _as_datetime(value).timetz().isoformat() + + +def _days_between(start: Any, end: Any) -> float: + return (_as_datetime(end) - _as_datetime(start)).total_seconds() / 86400 + + +def _hours_between(start: Any, end: Any) -> float: + return (_as_datetime(end) - _as_datetime(start)).total_seconds() / 3600 + + +def _hue_from_hex(value: Any) -> float: + if not isinstance(value, str): + raise ValueError("hue_from_hex expects a color string.") + normalized = value.strip().lstrip("#") + if len(normalized) == 3: + normalized = "".join(char * 2 for char in normalized) + if len(normalized) != 6: + raise ValueError("hue_from_hex expects a 3 or 6 digit hex color.") + red = int(normalized[0:2], 16) / 255 + green = int(normalized[2:4], 16) / 255 + blue = int(normalized[4:6], 16) / 255 + hue, _, _ = colorsys.rgb_to_hsv(red, green, blue) + return round(hue * 360, 3) + + +def _today() -> str: + return datetime.now(timezone.utc).date().isoformat() + + +def _left(value: Any, count: Any = 1) -> str: + text = str(value if value is not None else "") + try: + length = max(0, math.floor(float(count))) + except (TypeError, ValueError): + length = 1 + return text[:length] + + +def _right(value: Any, count: Any = 1) -> str: + text = str(value if value is not None else "") + try: + length = max(0, math.floor(float(count))) + except (TypeError, ValueError): + length = 1 + if length == 0: + return "" + return text[-length:] + + +JSON_LOGIC_ALLOWED_OPERATORS = { + "var", + "if", + "and", + "or", + "!", + "==", + "!=", + "<", + "<=", + ">", + ">=", + "+", + "-", + "*", + "/", + "%", + "min", + "max", + "round", + "floor", + "ceil", + "abs", + "cat", + "upper", + "lower", + "trim", + "length", + "replace", + "left", + "right", + "coalesce", + "today", + "year", + "month", + "day", + "hour", + "minute", + "second", + "timestamp", + "date_only", + "time_only", + "days_between", + "hours_between", + "hue_from_hex", +} + + +def _normalize_json_logic_args(raw_value: Any) -> list[Any]: + if isinstance(raw_value, list): + return raw_value + return [raw_value] + + +def _normalize_preview_result(value: Any) -> str | float | int | bool | None: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, (str, float, int, bool)) or value is None: + return value + return str(value) + + +def _truthy(value: Any) -> bool: + return bool(value) + + +def _lookup_reference(reference: Any, scope: dict[str, Any], default: Any = None) -> Any: + if isinstance(reference, int): + return scope.get(str(reference), default) + if not isinstance(reference, str): + raise ValueError("JSON Logic var reference must be a string or integer.") + if reference == "": + return scope + + current: Any = scope + for part in reference.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + continue + return default + return current + + +def _validate_json_logic_node(node: Any, references: set[str]) -> None: + if isinstance(node, (str, int, float, bool)) or node is None: + return + if isinstance(node, list): + for value in node: + _validate_json_logic_node(value, references) + return + if not isinstance(node, dict): + raise ValueError("JSON Logic expression contains unsupported value types.") + if len(node) != 1: + raise ValueError("JSON Logic expression objects must contain exactly one operator.") + + operator, raw_args = next(iter(node.items())) + if operator not in JSON_LOGIC_ALLOWED_OPERATORS: + raise ValueError(f"JSON Logic operator '{operator}' is not allowed.") + + args = _normalize_json_logic_args(raw_args) + if operator == "var": + if len(args) == 0: + raise ValueError("JSON Logic var operator requires at least one argument.") + reference = args[0] + if not isinstance(reference, (str, int)): + raise ValueError("JSON Logic var reference must be a string or integer.") + if isinstance(reference, str) and reference != "": + references.add(reference) + if len(args) > 1: + _validate_json_logic_node(args[1], references) + return + + for arg in args: + _validate_json_logic_node(arg, references) + + +def validate_derived_expression_json(expression_json: dict[str, Any]) -> list[str]: + references: set[str] = set() + _validate_json_logic_node(expression_json, references) + return sorted(references) + + +def _evaluate_json_logic(node: Any, scope: dict[str, Any]) -> Any: + if isinstance(node, (str, int, float, bool)) or node is None: + return node + if isinstance(node, list): + return [_evaluate_json_logic(value, scope) for value in node] + if not isinstance(node, dict) or len(node) != 1: + raise ValueError("JSON Logic expression uses an invalid object shape.") + + operator, raw_args = next(iter(node.items())) + args = _normalize_json_logic_args(raw_args) + + if operator == "var": + reference = args[0] if len(args) > 0 else "" + default = _evaluate_json_logic(args[1], scope) if len(args) > 1 else None + return _lookup_reference(reference, scope, default) + if operator == "if": + if len(args) < 2: + raise ValueError("JSON Logic if operator requires at least 2 arguments.") + for index in range(0, len(args) - 1, 2): + if _truthy(_evaluate_json_logic(args[index], scope)): + return _evaluate_json_logic(args[index + 1], scope) + if len(args) % 2 == 1: + return _evaluate_json_logic(args[-1], scope) + return None + if operator == "and": + result: Any = True + for arg in args: + result = _evaluate_json_logic(arg, scope) + if not _truthy(result): + return result + return result + if operator == "or": + result: Any = False + for arg in args: + result = _evaluate_json_logic(arg, scope) + if _truthy(result): + return result + return result + if operator == "!": + if len(args) != 1: + raise ValueError("JSON Logic ! operator requires one argument.") + return not _truthy(_evaluate_json_logic(args[0], scope)) + + evaluated_args = [_evaluate_json_logic(arg, scope) for arg in args] + + if operator == "==": + return evaluated_args[0] == evaluated_args[1] + if operator == "!=": + return evaluated_args[0] != evaluated_args[1] + if operator == "<": + return evaluated_args[0] < evaluated_args[1] + if operator == "<=": + return evaluated_args[0] <= evaluated_args[1] + if operator == ">": + return evaluated_args[0] > evaluated_args[1] + if operator == ">=": + return evaluated_args[0] >= evaluated_args[1] + if operator == "+": + return sum(evaluated_args) + if operator == "-": + if len(evaluated_args) == 1: + return -evaluated_args[0] + return evaluated_args[0] - evaluated_args[1] + if operator == "*": + result = 1 + for value in evaluated_args: + result *= value + return result + if operator == "/": + if len(evaluated_args) != 2: + raise ValueError("JSON Logic / operator requires two arguments.") + return evaluated_args[0] / evaluated_args[1] + if operator == "%": + return evaluated_args[0] % evaluated_args[1] + if operator == "min": + return min(evaluated_args) + if operator == "max": + return max(evaluated_args) + if operator == "round": + return round(evaluated_args[0]) + if operator == "floor": + return math.floor(evaluated_args[0]) + if operator == "ceil": + return math.ceil(evaluated_args[0]) + if operator == "abs": + return abs(evaluated_args[0]) + if operator == "cat": + return "".join(str(value) for value in evaluated_args) + if operator == "upper": + return str(evaluated_args[0]).upper() + if operator == "lower": + return str(evaluated_args[0]).lower() + if operator == "trim": + return str(evaluated_args[0]).strip() + if operator == "length": + return len(evaluated_args[0]) + if operator == "replace": + return str(evaluated_args[0]).replace(str(evaluated_args[1]), str(evaluated_args[2])) + if operator == "left": + return _left(evaluated_args[0], evaluated_args[1] if len(evaluated_args) > 1 else 1) + if operator == "right": + return _right(evaluated_args[0], evaluated_args[1] if len(evaluated_args) > 1 else 1) + if operator == "coalesce": + return _coalesce(*evaluated_args) + if operator == "today": + return _today() + if operator == "year": + return _as_datetime(evaluated_args[0]).year + if operator == "month": + return _as_datetime(evaluated_args[0]).month + if operator == "day": + return _as_datetime(evaluated_args[0]).day + if operator == "hour": + return _as_datetime(evaluated_args[0]).hour + if operator == "minute": + return _as_datetime(evaluated_args[0]).minute + if operator == "second": + return _as_datetime(evaluated_args[0]).second + if operator == "timestamp": + return _as_datetime(evaluated_args[0]).timestamp() + if operator == "date_only": + return _date_only(evaluated_args[0]) + if operator == "time_only": + return _time_only(evaluated_args[0]) + if operator == "days_between": + return _days_between(evaluated_args[0], evaluated_args[1]) + if operator == "hours_between": + return _hours_between(evaluated_args[0], evaluated_args[1]) + if operator == "hue_from_hex": + return _hue_from_hex(evaluated_args[0]) + + raise ValueError(f"JSON Logic operator '{operator}' is not implemented.") + + +def preview_derived_expression_json( + expression_json: dict[str, Any], + sample_values: dict[str, Any], +) -> DerivedFieldPreviewResponse: + references = validate_derived_expression_json(expression_json) + try: + result = _evaluate_json_logic(expression_json, sample_values) + except Exception as exc: + raise ValueError(str(exc)) from exc + return DerivedFieldPreviewResponse(result=_normalize_preview_result(result), references=references) + + +def preview_derived_payload( + *, + expression_json: dict[str, Any], + sample_values: dict[str, Any], +) -> DerivedFieldPreviewResponse: + return preview_derived_expression_json(expression_json, sample_values) + + +def _validate_expression_payload(expression_json: dict[str, Any]) -> None: + validate_derived_expression_json(expression_json) + + +def _parse_extra_field_value(value: Any) -> Any: + if not isinstance(value, str): + return value + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _normalize_formula_scope(value: Any) -> Any: + if isinstance(value, dict): + normalized: dict[str, Any] = {} + for key, nested in value.items(): + if key == "extra" and isinstance(nested, dict): + normalized[key] = { + extra_key: _parse_extra_field_value(extra_value) + for extra_key, extra_value in nested.items() + } + continue + normalized[key] = _normalize_formula_scope(nested) + return normalized + if isinstance(value, list): + return [_normalize_formula_scope(item) for item in value] + return value + + +def build_formula_scope(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize API payload values into a formula-evaluation scope.""" + normalized = _normalize_formula_scope(payload) + return normalized if isinstance(normalized, dict) else {} + + +def evaluate_derived_fields_for_scope( + *, + derived_fields: list[DerivedFieldDefinition], + scope: dict[str, Any], + entity_type: EntityType, + entity_id: int | None = None, +) -> dict[str, Any]: + """Evaluate a set of derived fields for one entity payload scope.""" + values: dict[str, Any] = {} + for field in derived_fields: + try: + result = _evaluate_json_logic(field.expression_json, scope) + values[field.key] = _normalize_preview_result(result) + except Exception as exc: + # Derived output is best-effort in API payloads so one invalid definition never blocks + # the base entity response for clients. + logger.warning( + "Failed to evaluate derived field %s for %s id=%s: %s", + field.key, + entity_type.name, + entity_id, + exc, + ) + return values + + +async def get_derived_fields(db: AsyncSession, entity_type: EntityType) -> list[DerivedFieldDefinition]: + """Return stored derived fields for an entity type.""" + if entity_type in _derived_field_cache: + return list(_derived_field_cache[entity_type]) + + setting_def = parse_setting(f"derived_fields_{entity_type.name}") + try: + setting = await db_setting.get(db, setting_def) + setting_value = setting.value + except ItemNotFoundError: + setting_value = setting_def.default + + parsed = json.loads(setting_value) + if not isinstance(parsed, list): + logger.warning("Setting %s is not a list, using default.", setting_def.key) + parsed = [] + + derived_fields: list[DerivedFieldDefinition] = [] + for raw_value in parsed: + if not isinstance(raw_value, dict): + continue + try: + derived_fields.append(DerivedFieldDefinition.model_validate(raw_value)) + except ValidationError as exc: + logger.warning( + "Skipping invalid derived field for %s (key=%s): %s", + entity_type.name, + raw_value.get("key"), + exc, + ) + + # Return a stable presentation order so settings tables and template variable lists do not + # re-shuffle unexpectedly when the stored JSON order changes. + derived_fields.sort(key=lambda item: (item.name.lower(), item.key)) + _derived_field_cache[entity_type] = derived_fields + return list(derived_fields) + + +async def get_derived_fields_for_surface( + db: AsyncSession, + entity_type: EntityType, + surface: str | None, + *, + api_enabled_only: bool = False, +) -> list[DerivedFieldDefinition]: + """Get derived fields filtered by surface, preserving the cached stable order.""" + derived_fields = await get_derived_fields(db, entity_type) + if surface is None: + filtered_fields = derived_fields + else: + filtered_fields = [field for field in derived_fields if surface in field.surfaces] + + if api_enabled_only: + # API exposure is a field-level opt-in, so formula definitions can remain available in UI + # surfaces without automatically becoming API output. + return [field for field in filtered_fields if field.include_in_api] + return filtered_fields + + +async def resolve_include_derived_in_api(db: AsyncSession, include_derived: bool | None) -> bool: + """Resolve per-request include_derived with a settings-level default.""" + if include_derived is not None: + return include_derived + + setting_def = parse_setting("api_include_derived_fields") + default_value = json.loads(setting_def.default) + try: + setting = await db_setting.get(db, setting_def) + except ItemNotFoundError: + return default_value + + try: + parsed = json.loads(setting.value) + except json.JSONDecodeError: + logger.warning("Setting %s is not valid JSON, using default.", setting_def.key) + return default_value + + if isinstance(parsed, bool): + return parsed + + logger.warning("Setting %s is not a boolean, using default.", setting_def.key) + return default_value + + +async def add_or_update_derived_field(db: AsyncSession, entity_type: EntityType, derived_field: DerivedFieldDefinition) -> None: + """Create or update a derived field.""" + _validate_expression_payload(derived_field.expression_json) + + existing = await get_derived_fields(db, entity_type) + next_fields = [field for field in existing if field.key != derived_field.key] + next_fields.append(derived_field) + next_fields.sort(key=lambda item: (item.name.lower(), item.key)) + + setting_def = parse_setting(f"derived_fields_{entity_type.name}") + await db_setting.update( + db=db, + definition=setting_def, + value=json.dumps([field.model_dump(mode="json") for field in next_fields]), + ) + _derived_field_cache[entity_type] = next_fields + + +async def delete_derived_field(db: AsyncSession, entity_type: EntityType, key: str) -> None: + """Delete a derived field.""" + existing = await get_derived_fields(db, entity_type) + next_fields = [field for field in existing if field.key != key] + if len(next_fields) == len(existing): + raise ItemNotFoundError(f"Derived field with key {key} does not exist.") + + setting_def = parse_setting(f"derived_fields_{entity_type.name}") + await db_setting.update( + db=db, + definition=setting_def, + value=json.dumps([field.model_dump(mode="json") for field in next_fields]), + ) + _derived_field_cache[entity_type] = next_fields diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce3193..83427aa9d 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -68,6 +68,10 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([])) +register_setting("derived_fields_vendor", SettingType.ARRAY, json.dumps([])) +register_setting("derived_fields_filament", SettingType.ARRAY, json.dumps([])) +register_setting("derived_fields_spool", SettingType.ARRAY, json.dumps([])) +register_setting("api_include_derived_fields", SettingType.BOOLEAN, json.dumps(obj=False)) register_setting("base_url", SettingType.STRING, json.dumps("")) register_setting("locations", SettingType.ARRAY, json.dumps([])) diff --git a/tests_integration/tests/fields/json_logic_parity_fixtures.json b/tests_integration/tests/fields/json_logic_parity_fixtures.json new file mode 100644 index 000000000..940d54b45 --- /dev/null +++ b/tests_integration/tests/fields/json_logic_parity_fixtures.json @@ -0,0 +1,142 @@ +[ + { + "id": "n01_add", + "result_type": "number", + "expression_json": { "+": [1, 2, 3] }, + "scope": {}, + "expected": 6 + }, + { + "id": "n02_var_subtract", + "result_type": "number", + "expression_json": { "-": [{ "var": "weight" }, { "var": "remaining_weight" }] }, + "scope": { "weight": 1000, "remaining_weight": 225 }, + "expected": 775 + }, + { + "id": "n03_round_divide", + "result_type": "number", + "expression_json": { "round": [{ "/": [{ "var": "remaining_weight" }, 1000] }] }, + "scope": { "remaining_weight": 225 }, + "expected": 0 + }, + { + "id": "n04_abs_negative", + "result_type": "number", + "expression_json": { "abs": [-32] }, + "scope": {}, + "expected": 32 + }, + { + "id": "b01_and_true", + "result_type": "boolean", + "expression_json": { "and": [{ ">": [{ "var": "remaining_weight" }, 0] }, { "==": [{ "var": "archived" }, false] }] }, + "scope": { "remaining_weight": 10, "archived": false }, + "expected": true + }, + { + "id": "b02_or_false", + "result_type": "boolean", + "expression_json": { "or": [{ "==": [{ "var": "material" }, "PLA" ] }, { "==": [{ "var": "material" }, "PETG"] }] }, + "scope": { "material": "ABS" }, + "expected": false + }, + { + "id": "b03_if_branch", + "result_type": "boolean", + "expression_json": { "if": [{ ">=": [{ "var": "used_weight" }, 900] }, true, false] }, + "scope": { "used_weight": 910 }, + "expected": true + }, + { + "id": "t01_concat", + "result_type": "text", + "expression_json": { "cat": ["Lot ", { "var": "lot_nr" }] }, + "scope": { "lot_nr": "A123" }, + "expected": "Lot A123" + }, + { + "id": "t02_upper", + "result_type": "text", + "expression_json": { "upper": [{ "var": "material" }] }, + "scope": { "material": "pla" }, + "expected": "PLA" + }, + { + "id": "t03_coalesce", + "result_type": "text", + "expression_json": { "coalesce": [{ "var": "extra.short_name" }, { "var": "name" }, "Unknown"] }, + "scope": { "name": "Basic PLA" }, + "expected": "Basic PLA" + }, + { + "id": "d01_today", + "result_type": "date", + "expression_json": { "today": [] }, + "scope": {}, + "expected_shape": "yyyy-mm-dd" + }, + { + "id": "d02_date_only", + "result_type": "date", + "expression_json": { "date_only": [{ "var": "created_at" }] }, + "scope": { "created_at": "2026-02-28T10:15:00Z" }, + "expected": "2026-02-28" + }, + { + "id": "dt01_identity", + "result_type": "datetime", + "expression_json": { "var": "last_used" }, + "scope": { "last_used": "2026-03-01T18:42:10Z" }, + "expected": "2026-03-01T18:42:10Z" + }, + { + "id": "tm01_time_only", + "result_type": "time", + "expression_json": { "time_only": [{ "var": "last_used" }] }, + "scope": { "last_used": "2026-03-01T18:42:10Z" }, + "expected": "18:42:10" + }, + { + "id": "n05_days_between", + "result_type": "number", + "expression_json": { "days_between": [{ "var": "first_used" }, { "var": "last_used" }] }, + "scope": { "first_used": "2026-03-01T00:00:00Z", "last_used": "2026-03-04T12:00:00Z" }, + "expected": 3.5 + }, + { + "id": "n06_hours_between", + "result_type": "number", + "expression_json": { "hours_between": [{ "var": "first_used" }, { "var": "last_used" }] }, + "scope": { "first_used": "2026-03-01T00:00:00Z", "last_used": "2026-03-01T18:00:00Z" }, + "expected": 18 + }, + { + "id": "n07_hue_from_hex", + "result_type": "number", + "expression_json": { "hue_from_hex": ["#FF0000"] }, + "scope": {}, + "expected": 0 + }, + { + "id": "inv01_unknown_operator", + "result_type": "number", + "expression_json": { "sqrt": [9] }, + "scope": {}, + "expect_error": "operator_not_allowed" + }, + { + "id": "inv02_missing_reference", + "result_type": "number", + "expression_json": { "+": [{ "var": "does_not_exist" }, 1] }, + "scope": {}, + "expect_error": "missing_reference" + }, + { + "id": "inv03_type_mismatch", + "result_type": "number", + "expression_json": { "cat": ["a", "b"] }, + "scope": {}, + "expect_error": "result_type_mismatch" + } +] diff --git a/tests_integration/tests/fields/test_derived.py b/tests_integration/tests/fields/test_derived.py new file mode 100644 index 000000000..b42f8f54a --- /dev/null +++ b/tests_integration/tests/fields/test_derived.py @@ -0,0 +1,60 @@ +"""Integration tests for derived (formula) fields.""" + +import httpx + +from ..conftest import URL, assert_httpx_code, assert_httpx_success + + +def test_preview_derived_json_logic_expression(): + """Preview endpoint should accept JSON Logic payloads.""" + result = httpx.post( + f"{URL}/api/v1/field/derived/spool/preview", + json={ + "expression_json": {"-": [{"var": "weight"}, {"var": "remaining_weight"}]}, + "sample_values": {"weight": 1000, "remaining_weight": 225}, + }, + ) + assert_httpx_success(result) + payload = result.json() + assert payload["result"] == 775 + assert set(payload["references"]) == {"weight", "remaining_weight"} + + +def test_create_and_delete_derived_json_logic_field(): + """Derived fields should persist expression_json definitions.""" + key = "json_logic_test_field" + + create_result = httpx.post( + f"{URL}/api/v1/field/derived/spool/{key}", + json={ + "name": "JSON Logic Test Field", + "description": "Created by integration test", + "result_type": "number", + "expression_json": {"+": [1, 2, 3]}, + "surfaces": ["show"], + "allow_list_column_toggle": False, + }, + ) + assert_httpx_success(create_result) + fields = create_result.json() + created = next(field for field in fields if field["key"] == key) + assert created["expression_json"] == {"+": [1, 2, 3]} + # Transitional behavior keeps a string representation for legacy consumers that still read + # the expression column while JSON Logic is introduced. + assert created["expression"] is not None + + delete_result = httpx.delete(f"{URL}/api/v1/field/derived/spool/{key}") + assert_httpx_success(delete_result) + + +def test_preview_derived_json_logic_invalid_operator(): + """Preview should reject unknown JSON Logic operators with HTTP 400.""" + result = httpx.post( + f"{URL}/api/v1/field/derived/spool/preview", + json={ + "expression_json": {"sqrt": [9]}, + "sample_values": {}, + }, + ) + assert_httpx_code(result, 400) + assert "not allowed" in result.json()["message"] diff --git a/tests_integration/tests/fields/test_derived_api.py b/tests_integration/tests/fields/test_derived_api.py new file mode 100644 index 000000000..fa507c45c --- /dev/null +++ b/tests_integration/tests/fields/test_derived_api.py @@ -0,0 +1,98 @@ +"""Integration tests for derived field API payload exposure.""" + +from typing import Any + +import httpx +import pytest + +from ..conftest import URL, assert_httpx_success + + +def _set_api_include_derived(enabled: bool | None) -> None: + if enabled is None: + result = httpx.post(f"{URL}/api/v1/setting/api_include_derived_fields", json="") + else: + result = httpx.post( + f"{URL}/api/v1/setting/api_include_derived_fields", + json="true" if enabled else "false", + ) + assert_httpx_success(result) + + +def _create_spool_formula_field(key: str, *, include_in_api: bool) -> None: + create_result = httpx.post( + f"{URL}/api/v1/field/derived/spool/{key}", + json={ + "name": "API Derived Exposure Test", + "description": "Created by integration test", + "result_type": "number", + "expression_json": {"+": [{"var": "used_weight"}, {"var": "remaining_weight"}]}, + "surfaces": ["show", "list"], + "allow_list_column_toggle": False, + "include_in_api": include_in_api, + }, + ) + assert_httpx_success(create_result) + + +def _delete_spool_formula_field(key: str) -> None: + delete_result = httpx.delete(f"{URL}/api/v1/field/derived/spool/{key}") + assert_httpx_success(delete_result) + + +def test_spool_api_include_derived_toggle(random_filament: dict[str, Any]): + """Derived API output should support default setting and per-request overrides.""" + key = "api_include_derived_toggle" + hidden_key = "api_excluded_field" + _create_spool_formula_field(key, include_in_api=True) + _create_spool_formula_field(hidden_key, include_in_api=False) + + spool_create = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "remaining_weight": 800, + }, + ) + assert_httpx_success(spool_create) + spool = spool_create.json() + + try: + _set_api_include_derived(None) + + default_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}") + assert_httpx_success(default_response) + assert "derived" not in default_response.json() + + explicit_enabled_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}", params={"include_derived": "true"}) + assert_httpx_success(explicit_enabled_response) + explicit_enabled_payload = explicit_enabled_response.json() + assert explicit_enabled_payload["derived"][key] == pytest.approx(1000) + assert hidden_key not in explicit_enabled_payload["derived"] + + _set_api_include_derived(True) + + default_enabled_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}") + assert_httpx_success(default_enabled_response) + default_enabled_payload = default_enabled_response.json() + assert default_enabled_payload["derived"][key] == pytest.approx(1000) + assert hidden_key not in default_enabled_payload["derived"] + + explicit_disabled_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}", params={"include_derived": "false"}) + assert_httpx_success(explicit_disabled_response) + assert "derived" not in explicit_disabled_response.json() + + list_enabled_response = httpx.get( + f"{URL}/api/v1/spool", + params={"filament.id": str(random_filament["id"]), "include_derived": "true"}, + ) + assert_httpx_success(list_enabled_response) + list_payload = list_enabled_response.json() + matching_spool = next(item for item in list_payload if item["id"] == spool["id"]) + assert matching_spool["derived"][key] == pytest.approx(1000) + assert hidden_key not in matching_spool["derived"] + finally: + httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() + _delete_spool_formula_field(hidden_key) + _delete_spool_formula_field(key) + _set_api_include_derived(None) From ed9df0c0e4b170b63f6071492032e724c32cd435 Mon Sep 17 00:00:00 2001 From: akira69 Date: Tue, 10 Mar 2026 08:08:25 -0500 Subject: [PATCH 2/6] feat(formula-fields): UI improvements - consolidate help text to tooltips, remove clutter, add guidance icons --- client/public/locales/en/common.json | 95 +- client/src/pages/filaments/list.tsx | 20 +- client/src/pages/filaments/show.tsx | 6 +- client/src/pages/help/index.tsx | 45 +- .../pages/settings/complexFieldsSettings.tsx | 1834 ----------- .../pages/settings/extraFieldsSettings.tsx | 2 +- .../pages/settings/formulaFieldsSettings.tsx | 2711 +++++++++++++++++ client/src/pages/spools/list.tsx | 20 +- client/src/pages/spools/show.tsx | 6 +- client/src/pages/vendors/list.tsx | 20 +- client/src/pages/vendors/show.tsx | 6 +- client/src/utils/formulaFields.ts | 67 +- client/src/utils/queryFields.ts | 3 +- scripts/pr-preflight.sh | 174 -- spoolman/derived_fields.py | 20 +- 15 files changed, 2903 insertions(+), 2126 deletions(-) delete mode 100644 client/src/pages/settings/complexFieldsSettings.tsx create mode 100644 client/src/pages/settings/formulaFieldsSettings.tsx delete mode 100755 scripts/pr-preflight.sh diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 294ec7752..f4ef28375 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -362,48 +362,27 @@ "delete_confirm": "Delete field {{name}}?", "delete_confirm_description": "This will delete the field and all associated data for all entities.", "delete_dependency_warning_intro": "Deleting this custom field will make dependent fields inoperable:", - "delete_dependency_warning_complex": "Complex extra fields: {{dependencies}}", "delete_dependency_warning_formula": "Formula extra fields: {{dependencies}}", "delete_dependency_warning_footer": "These entries remain saved, but behavior depending on this field will fail until references are updated." }, - "complex_fields": { - "tab": "Complex Extra Fields", - "intro": "Complex extra fields are optional app-provided feature modules that extend built-in and custom fields for this entity. They are enabled here when available.", - "table_note": "Complex extra field definitions are provided by installed feature modules and are not created from this page.", - "missing_references_intro": "Some complex extra fields reference custom fields that are no longer available.", - "missing_references": "Missing custom extra field references: {{references}}", - "description": "

Complex fields add optional, pre-defined behaviors beyond custom extra fields.

Enabling one can add specialized display, actions, calculated values, or list columns for the selected entity. Disabled features remain hidden so the standard UI stays simpler.

Each entry below states exactly what enabling it adds. This framework can stay empty until a specific advanced feature is installed.

", - "tooltip": "Complex extra fields are app-provided feature modules. Enabling one can add specialized display, actions, calculated values, or list columns for this entity.", + "formula_fields": { "help_links": { - "complex": "Help: Complex Extra Fields", "formula": "Help: Formula Extra Fields", "formula_json": "Help: JSON Logic", "formula_tokens": "Help: Token Groups" }, - "empty": "No complex extra field feature modules are currently registered for this entity.", "available_functions": { "label": "Token categories:", "value": "Operators, helper functions, and field references are grouped in the editor." }, - "columns": { - "name": "Name", - "description": "Feature", - "enable_description": "What Enabling Adds", - "surfaces": "Display In", - "enabled": "Enabled" - }, "surfaces": { - "show": "Show", + "show": "Show Pages", "edit": "Edit", - "list": "List", - "template": "Template", + "list": "Tables", + "template": "Template Selections", "action": "Action", "derived": "Derived" }, - "messages": { - "enabled": "Enabled {{name}}.", - "disabled": "Disabled {{name}}." - }, "formula": { "header": "Formula Extra Fields", "intro": "Formula extra fields are read-only derived values computed from expressions that reference existing fields.", @@ -413,39 +392,46 @@ "empty": "No formula fields are currently defined for this entity.", "columns": { "key": "Key", + "path": "Template & API Path", "name": "Name", "description": "Description", - "result_type": "Result Type", "expression": "Expression", - "expression_json": "Expression JSON (JSON Logic)", + "expression_json": "Formula Extra Field Expression (JSON Logic)", "surfaces": "Display In", - "allow_list_column_toggle": "Hide Columns Toggle", "include_in_api": "Include in API" }, + "display_targets": { + "show_pages": "Show Pages", + "template_selections": "Template Selections", + "tables": "Tables", + "api": "API" + }, "types": { "number": "Number", "text": "Text" }, - "result_type_mismatch_hint": "Expression JSON appears to return {{inferred}}. You can keep the current type or auto-set it.", - "result_type_autoset": "Auto-set", "tooltips": { - "key": "Stable machine key for this formula field. It must be unique, uses lowercase letters/numbers/underscores, and is what later integrations will refer to.", - "name": "Human-friendly label shown in the UI for this formula field.", - "display_in": "Choose where this formula field is intended to appear later. Show means detail pages. List means table or list views. Template means label, title, or filename template variables.", - "allow_list_column_toggle": "If enabled, list-display formula fields can be hidden or shown from the Hide Columns menu. If disabled, they stay visible whenever the field is shown in lists.", - "include_in_api": "When enabled, this field can be included in API responses under payload.derived whenever include_derived is enabled for the request.", - "expression_json": "JSON Logic object used to compute this formula field.", - "sample_values": "Variable definition examples and formatting: {\"weight\": 1000, \"remaining_weight\": 225, \"created_at\": \"2026-02-28T10:15:00Z\", \"color_hex\": \"#FF00FF\"}" + "key": "Unique identifier for this formula field. Uses lowercase letters, numbers, and underscores. Later integrations will reference it as 'derived.'.", + "name": "Human-friendly display label for this field in the UI.", + "display_in": "Choose where this calculated field appears: in show/edit pages, tables, templates, or API responses.", + "include_in_api": "When enabled, include this field in API responses under the derived object.", + "expression_json": "JSON Logic expression that computes this field. Combine operators, helper functions, and field references below.", + "sample_values": "Test data object to preview results. Provide sample values for all references used in your expression." }, - "allow_list_column_toggle_help": "Only applies when List display is selected. This keeps optional formula columns discoverable in the existing Hide Columns picker.", - "allow_list_column_toggle_inline": "Enable column visibility in {{entity}} view.", + "expression_json_copy_tooltip": "Copy expression JSON to clipboard", + "expression_json_copied": "Expression copied!", "sample_values": "Sample Values (JSON)", - "sample_values_help": "Define JSON variables for preview/testing of this expression.", - "expression_json_help": "Enter a JSON Logic object manually or using helper/reference tokens and operators to insert snippets.", + "sample_values_help": "Enter a JSON object with key-value pairs matching your expression references", + "sample_values_detected_references": "Detected references:", + "sample_values_detected_references_empty": "No references detected yet", + "sample_values_reference_invalid": "Variable undefined or incorrectly defined in Sample Values JSON.", + "expression_json_help": "Enter a JSON Logic expression. Type manually or use the operator/helper/reference tokens below to build it step-by-step.", "expression_json_example": "Example: {\"-\": [{\"var\": \"weight\"}, {\"var\": \"remaining_weight\"}]}", "expression_json_required": "Expression JSON (JSON Logic) is required.", - "key_usage_help": "API/template path", - "key_reserved_hint": "This key matches a formula token name ({{key}}). It still works, but choosing a distinct key can reduce confusion.", + "expression_json_invalid": "Expression JSON must be valid JSON format (an object like {...}).", + "sample_values_invalid": "Sample Values must be valid JSON format (an object like {...}).", + "key_usage_help": "Template & API Path", + "key_reserved_hint": "This key matches a built-in operator or helper ({{key}}). It works, but a different name will be clearer for users.", "operator_groups": { "logical": "Logical / Conditional", "comparison": "Comparison", @@ -469,20 +455,27 @@ }, "reference_picker": { "label": "Field References", - "placeholder": "Pick a field to insert into the expression", - "help": "Tokens are clickable inserts. Helper Functions are functional tokens, and Field References insert {var} references for built-in or configured extra fields." + "placeholder": "Pick a field to insert as a reference", + "help": "Click field references to insert them directly. Helper functions require you to select compatible references first. Both built-in fields and custom extra fields are available." }, "json_builder": { "operators_title": "Insert Tokens", - "click_to_insert_help": "Select a helper, then select compatible field references. Field references insert JSON snippets immediately. Use Helper only to insert placeholder arguments.", + "click_to_insert_help": "Click field references to insert them immediately. For helpers, select compatible references first, or use the 'Helper only' button to insert with placeholder arguments.", "pending_helper": "Pending reference for helper {{helper}} ({{selected}}/{{total}})", "pending_helper_prefix": "Pending reference for helper", "pending_helper_count": "({{selected}}/{{total}})", + "if_step_condition_operator": "Next: select IF condition operator", + "if_step_condition_left": "Next: select IF condition left operand", + "if_step_condition_right": "Next: select IF condition right operand", + "if_step_then": "Next: select IF Then value", + "if_step_else": "Next: select IF Else value", "helper_unavailable_reason": "Helper {{helper}} has no compatible references for this entity yet.", "helper_incompatible_reason": "Helper {{helper}} is incompatible with the currently selected pending reference type.", "reference_incompatible_reason": "Selected reference is incompatible with helper {{helper}}.", "show_operators": "Show operators", "hide_operators": "Hide operators", + "show_tokens": "Show tokens", + "hide_tokens": "Hide tokens", "operator_compact": { "logical_top": "Logical", "logical_bottom": "Conditional", @@ -509,10 +502,12 @@ "missing_references_intro": "Some formula fields reference custom fields that are no longer available.", "missing_references": "Missing custom field references: {{references}}", "preview": { - "button": "Preview Expression", - "result_label": "Preview:", - "references_used": "References used: {{references}}", - "no_references": "No field references are used in this expression." + "button": "Refresh", + "loading": "Computing preview...", + "panel_title": "Preview", + "empty": "Waiting for valid expression/sample values.", + "error_fallback": "Preview failed.", + "refresh_tooltip": "Re-sync missing sample keys and immediately re-run preview." } } } diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index fd05f9055..6112bad7c 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -26,7 +26,7 @@ import { useSpoolmanVendors, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; -import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { FormulaFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { useCurrencyFormatter } from "../../utils/settings"; import { IFilament } from "./model"; @@ -147,16 +147,14 @@ export const FilamentList = () => { ); const liveDataSource = useLiveify("filament", queryDataSource, collapseFilament); const listFormulaFields = useMemo( - () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.list), + () => getFormulaFieldsForSurface(formulaFields.data, FormulaFieldSurface.list), [formulaFields.data], ); - const toggleableListFormulaFields = useMemo( - () => listFormulaFields.filter((field) => field.allow_list_column_toggle), - [listFormulaFields], - ); + // All list-surface formula fields are eligible for hide/show in the column picker, + // so we map every list formula to its derived column key here. const toggleableDerivedColumnKeys = useMemo( - () => toggleableListFormulaFields.map((field) => `derived.${field.key}`), - [toggleableListFormulaFields], + () => listFormulaFields.map((field) => `derived.${field.key}`), + [listFormulaFields], ); const allColumnsWithExtraFields = useMemo( () => [ @@ -204,6 +202,8 @@ export const FilamentList = () => { }; const updateColumnSelections = (selectedKeys: string[]) => { + // Persist core column visibility separately from derived-column visibility so + // derived keys can be toggled without rewriting the base showColumns state. setShowColumns(selectedKeys.filter((key) => !toggleableDerivedColumnKeys.includes(key))); setHiddenDerivedColumns(toggleableDerivedColumnKeys.filter((key) => !selectedKeys.includes(key))); }; @@ -235,7 +235,7 @@ export const FilamentList = () => { }; } if (column_id.indexOf("derived.") === 0) { - const formulaField = toggleableListFormulaFields.find((field) => `derived.${field.key}` === column_id); + const formulaField = listFormulaFields.find((field) => `derived.${field.key}` === column_id); return { key: column_id, label: formulaField?.name ?? column_id, @@ -388,7 +388,7 @@ export const FilamentList = () => { ...listFormulaFields.map( (field) => { const derivedColumnKey = `derived.${field.key}`; - if (field.allow_list_column_toggle && hiddenDerivedColumns.includes(derivedColumnKey)) { + if (hiddenDerivedColumns.includes(derivedColumnKey)) { return undefined; } diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx index 4c98fe19d..263e75914 100644 --- a/client/src/pages/filaments/show.tsx +++ b/client/src/pages/filaments/show.tsx @@ -10,7 +10,7 @@ import { NumberFieldUnit } from "../../components/numberField"; import SpoolIcon from "../../components/spoolIcon"; import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { enrichText } from "../../utils/parsing"; -import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { FormulaFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; import { IFilament } from "./model"; dayjs.extend(utc); @@ -30,7 +30,7 @@ export const FilamentShow = () => { const record = data?.data; const showFormulaFields = useMemo( - () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.show), + () => getFormulaFieldsForSurface(formulaFields.data, FormulaFieldSurface.show), [formulaFields.data], ); const derivedValues = useMemo( @@ -162,7 +162,7 @@ export const FilamentShow = () => { {extraFields?.data?.map((field, index) => ( ))} - {showFormulaFields.length > 0 && {t("settings.complex_fields.formula.header")}} + {showFormulaFields.length > 0 && {t("settings.formula_fields.formula.header")}} {showFormulaFields.map((field) => ( {field.name} diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx index 3ee37bc86..e09f82f85 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -72,10 +72,11 @@ const BUILT_IN_FIELD_DEFINITIONS: Record = [ { label: "Logical / Conditional", operators: ["if", "and", "or", "!"] }, { label: "Comparison", operators: ["==", "!=", "<", "<=", ">", ">="] }, - { label: "Arithmetic", operators: ["+", "-", "*", "/", "%"] }, + { label: "Arithmetic", operators: ["+", "-", "*", "/", "%", "floor"] }, ]; export const Help = () => { @@ -306,13 +307,21 @@ export const Help = () => { Configure them in Settings → Extra Fields for Spools, Filaments, or Manufacturers. In Formula Extra Fields, click +, build your JSON - expression, then validate with Sample Values (JSON) and Preview Expression{" "} - before saving. + expression, then validate with Sample Values (JSON) and Refresh before + saving. - In each formula field editor, use Include in API to mark that field as eligible for API - output. Entity responses include only those field-level opt-ins under a derived object - whenever include_derived=true is requested. Each field key is exposed as{" "} + In each formula field editor, Display In uses visible checkboxes for{" "} + Show Pages, Template Selections, and Tables, with{" "} + API in the same row for payload exposure. + + + Show Pages and Tables display the field Name in UI. + Template/API integrations use the key path {`derived.`}. + + + Entity responses include only field-level API opt-ins under a derived object when + derived output is requested by the endpoint. Each field key is exposed as{" "} {`derived.`}. @@ -406,7 +415,7 @@ export const Help = () => { padding: 8, }} > - {t(`settings.complex_fields.formula.token_categories.${group.key}`)} + {t(`settings.formula_fields.formula.token_categories.${group.key}`)}
{group.helpers.map((helper) => ( @@ -459,7 +468,7 @@ export const Help = () => { padding: 10, }} > - Example 2: Difference between two datetimes + Example 2: Completed days between two datetimes (integer) Variable definitions: @@ -467,9 +476,9 @@ export const Help = () => { Expression JSON: - {`{"days_between":[{"var":"first_used"},{"var":"last_used"}]}`} + {`{"floor":[{"days_between":[{"var":"first_used"},{"var":"last_used"}]}]}`} - Result: {`8.25`} + Result: {`8`}
{ The expression editor uses a JSON code editor (CodeMirror). Use Format JSON to - auto-pretty-print your JSON Logic object. Keep Preview Expression +{" "} + auto-pretty-print your JSON Logic object. Keep Refresh +{" "} Sample Values (JSON) as your first validation pass. @@ -508,6 +517,10 @@ export const Help = () => { keys without braces, and match keys to your {`{"var":"..."}`} references. Example:{" "} {`{"weight": 1000, "remaining_weight": 225, "created_at": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}`}. + + The editor also shows detected references from your expression and auto-scaffolds missing sample-value keys + without overwriting existing sample data. Use Refresh to force a re-sync and preview run. + Reference docs:{" "} @@ -523,16 +536,16 @@ export const Help = () => { - Choose where each formula appears: Show (record details), List{" "} - (table/list pages), and Template (label/title/filename templates). + Choose where each formula appears: Show Pages (record details),{" "} + Tables (table/list pages), and Template Selections{" "} + (label/title/filename templates).
  • - If a formula includes List, you can enable column toggling so it can be hidden or shown - through Hide Columns on list pages. + Tables controls whether the formula appears in list/table pages at all.
  • - If a formula includes Template, it can be referenced in templates as{" "} + If a formula includes Template Selections, it can be referenced in templates as{" "} {`{derived.your_key}`} (for example, {`{derived.days_between_events}`}).
diff --git a/client/src/pages/settings/complexFieldsSettings.tsx b/client/src/pages/settings/complexFieldsSettings.tsx deleted file mode 100644 index 0b95cac35..000000000 --- a/client/src/pages/settings/complexFieldsSettings.tsx +++ /dev/null @@ -1,1834 +0,0 @@ -import { json } from "@codemirror/lang-json"; -import { EditorView, drawSelection } from "@codemirror/view"; -import CodeMirror from "@uiw/react-codemirror"; -import { - CloseCircleOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - PlusOutlined, - QuestionCircleOutlined, - WarningOutlined, -} from "@ant-design/icons"; -import { useTranslate } from "@refinedev/core"; -import { - Button, - Col, - Divider, - Empty, - Flex, - Form, - Grid, - Input, - Modal, - Popconfirm, - Row, - Select, - Space, - Switch, - Table, - Tag, - Tooltip, - Typography, - message, - theme, -} from "antd"; -import { ColumnType } from "antd/es/table"; -import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { Link, useParams } from "react-router"; -import { - FORMULA_HELPER_GROUPS, - FORMULA_HELPERS, - FormulaHelperDefinition, - getExtraFieldReferences, -} from "../../utils/formulaFields"; -import { - ComplexFieldSurface, - DerivedField, - DerivedFieldType, - EntityType, - FieldType, - useDeleteDerivedField, - useGetDerivedFields, - useGetFields, - usePreviewDerivedField, - useSetDerivedField, -} from "../../utils/queryFields"; - -const DERIVED_SURFACE_OPTIONS = [ComplexFieldSurface.show, ComplexFieldSurface.list, ComplexFieldSurface.template]; -const BUILTIN_REFERENCE_SUGGESTIONS: Record = { - vendor: ["id", "name", "registered", "comment"], - filament: ["id", "name", "material", "price", "density", "weight", "color_hex", "comment"], - spool: ["id", "weight", "remaining_weight", "used_weight", "price", "lot_nr", "comment", "created_at"], -}; -const SAMPLE_VALUE_PLACEHOLDERS: Record = { - vendor: '{"name": "Example Vendor", "registered": "2026-02-28T10:15:00Z"}', - filament: '{"weight": 1000, "material": "PLA", "created_at": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}', - spool: '{"weight": 1000, "remaining_weight": 225, "created_at": "2026-02-28T10:15:00Z"}', -}; -const JSON_LOGIC_OPERATOR_GROUPS: Array<{ key: string; operators: string[] }> = [ - { key: "logical", operators: ["if", "and", "or", "!"] }, - { key: "comparison", operators: ["==", "!=", "<", "<=", ">", ">="] }, - { key: "arithmetic", operators: ["+", "-", "*", "/", "%"] }, -]; -const OPERATOR_PANEL_WIDTH = 244; -const INLINE_OPERATOR_PANEL_HEIGHT = 264; -const HELPER_DESKTOP_COLUMN_LAYOUT: Array<{ top: string; bottom?: string }> = [ - { top: "math", bottom: "color" }, - { top: "text" }, - { top: "datetime" }, - { top: "dynamic", bottom: "date_diff" }, -]; -const JSON_LOGIC_OPERATOR_SNIPPETS: Record = { - if: '{\n "if": [\n {"var": "condition"},\n "then_value",\n "else_value"\n ]\n}', - and: '{\n "and": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - or: '{\n "or": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "!": '{\n "!": [\n {"var": "value"}\n ]\n}', - "==": '{\n "==": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "!=": '{\n "!=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "<": '{\n "<": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "<=": '{\n "<=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - ">": '{\n ">": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - ">=": '{\n ">=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "+": '{\n "+": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "-": '{\n "-": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "*": '{\n "*": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "/": '{\n "/": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', - "%": '{\n "%": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', -}; -const RESERVED_DERIVED_KEY_NAMES = new Set([ - ...JSON_LOGIC_OPERATOR_GROUPS.flatMap((group) => group.operators), - ...FORMULA_HELPERS.map((helper) => helper.name), -]); - -type ReferenceValueKind = "any" | "number" | "datetime" | "text" | "boolean" | "range" | "unknown"; -type PendingHelperInsertState = { - helperName: string; - selectedReferences: string[]; -}; -type FormulaResultTypeHint = "number" | "text" | "boolean" | "unknown"; - -const BUILTIN_REFERENCE_KIND_HINTS: Record> = { - vendor: { - id: "number", - name: "text", - registered: "datetime", - comment: "text", - }, - filament: { - id: "number", - name: "text", - material: "text", - price: "number", - density: "number", - weight: "number", - color_hex: "text", - comment: "text", - }, - spool: { - id: "number", - weight: "number", - remaining_weight: "number", - used_weight: "number", - price: "number", - lot_nr: "text", - comment: "text", - created_at: "datetime", - }, -}; - -function resolveColorLuminance(color: string): number | null { - const normalized = color.trim().toLowerCase(); - - const hexMatch = normalized.match(/^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/); - if (hexMatch) { - const hex = hexMatch[1]; - const value = - hex.length === 3 || hex.length === 4 - ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` - : hex.slice(0, 6); - const r = parseInt(value.slice(0, 2), 16); - const g = parseInt(value.slice(2, 4), 16); - const b = parseInt(value.slice(4, 6), 16); - return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; - } - - const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/); - if (!rgbMatch) { - return null; - } - const channels = rgbMatch[1] - .split(",") - .map((part) => part.trim()) - .slice(0, 3); - if (channels.length !== 3) { - return null; - } - - const toByte = (channel: string): number | null => { - if (channel.endsWith("%")) { - const percent = Number(channel.slice(0, -1)); - if (Number.isNaN(percent)) { - return null; - } - return Math.round((Math.max(0, Math.min(100, percent)) / 100) * 255); - } - const value = Number(channel); - if (Number.isNaN(value)) { - return null; - } - return Math.max(0, Math.min(255, value)); - }; - - const r = toByte(channels[0]); - const g = toByte(channels[1]); - const b = toByte(channels[2]); - if (r == null || g == null || b == null) { - return null; - } - return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; -} - -function formatPreviewValue(value: string | number | boolean | null): string { - if (value === null) { - return "null"; - } - return `${value}`; -} - -function parseSampleValues(raw: string | undefined): Record { - if (!raw || raw.trim() === "") { - return {}; - } - - const parsed = JSON.parse(raw); - if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { - throw new Error("Sample values must be a JSON object."); - } - - return parsed as Record; -} - -function parseExpressionJson(raw: string | undefined): Record | undefined { - if (!raw || raw.trim() === "") { - return undefined; - } - - const parsed = JSON.parse(raw); - if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { - throw new Error("Expression JSON must be a JSON object."); - } - return parsed as Record; -} - -function mergeTypeHints(typeHints: FormulaResultTypeHint[]): FormulaResultTypeHint { - const knownHints = typeHints.filter((typeHint) => typeHint !== "unknown"); - if (knownHints.length === 0) { - return "unknown"; - } - return knownHints.every((typeHint) => typeHint === knownHints[0]) ? knownHints[0] : "unknown"; -} - -function inferExpressionJsonType(node: unknown): FormulaResultTypeHint { - if (typeof node === "number") { - return "number"; - } - if (typeof node === "string") { - return "text"; - } - if (typeof node === "boolean") { - return "boolean"; - } - if (node === null || Array.isArray(node) || typeof node !== "object") { - return "unknown"; - } - - const entries = Object.entries(node as Record); - if (entries.length !== 1) { - return "unknown"; - } - - const [operator, rawArgs] = entries[0]; - const args = Array.isArray(rawArgs) ? rawArgs : [rawArgs]; - - if (operator === "var") { - return "unknown"; - } - - if (operator === "if") { - const branchHints: FormulaResultTypeHint[] = []; - for (let index = 1; index < args.length; index += 2) { - branchHints.push(inferExpressionJsonType(args[index])); - } - if (args.length % 2 === 0 && args.length > 0) { - branchHints.push(inferExpressionJsonType(args[args.length - 1])); - } - return mergeTypeHints(branchHints); - } - - if (operator === "coalesce") { - return mergeTypeHints(args.map((arg) => inferExpressionJsonType(arg))); - } - - if (["==", "!=", "<", "<=", ">", ">=", "!", "and", "or"].includes(operator)) { - return "boolean"; - } - - if ( - [ - "+", - "-", - "*", - "/", - "%", - "abs", - "min", - "max", - "round", - "year", - "month", - "day", - "hour", - "minute", - "second", - "timestamp", - "days_between", - "hours_between", - "hue_from_hex", - "length", - ].includes(operator) - ) { - return "number"; - } - - if (["date_only", "time_only", "today", "cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes(operator)) { - return "text"; - } - - return "unknown"; -} - -function toDerivedFieldType(typeHint: FormulaResultTypeHint): DerivedFieldType | null { - if (typeHint === "number") { - return DerivedFieldType.number; - } - if (typeHint === "text") { - return DerivedFieldType.text; - } - return null; -} - -export function FormulaFieldsSettings() { - const { entityType } = useParams<{ entityType: EntityType }>(); - const t = useTranslate(); - const { token } = theme.useToken(); - const screens = Grid.useBreakpoint(); - const [messageApi, contextHolder] = message.useMessage(); - const [derivedModalOpen, setDerivedModalOpen] = useState(false); - const [editingDerivedKey, setEditingDerivedKey] = useState(null); - const [previewText, setPreviewText] = useState(null); - const [previewReferences, setPreviewReferences] = useState([]); - const [pendingJsonHelperInsert, setPendingJsonHelperInsert] = useState(null); - const [resultTypeMismatchHint, setResultTypeMismatchHint] = useState(null); - const [operatorPanelCollapsed, setOperatorPanelCollapsed] = useState(false); - const [hoveredTokenId, setHoveredTokenId] = useState(null); - const [derivedForm] = Form.useForm(); - const expressionJsonEditorRef = useRef(null); - const expressionJsonSelectionRef = useRef<{ from: number; to: number }>({ from: 0, to: 0 }); - - const selectedEntityType = entityType as EntityType; - const niceName = t(`${selectedEntityType}.${selectedEntityType}`); - const sectionBodyStyle = { marginTop: 0, fontSize: token.fontSize, lineHeight: 1.7 }; - const tokenPanelStyle = useMemo( - () => ({ - border: `1px solid ${token.colorBorderSecondary}`, - borderRadius: token.borderRadiusLG, - padding: 10, - background: token.colorBgContainer, - }), - [token.colorBgContainer, token.colorBorderSecondary, token.borderRadiusLG], - ); - const tokenCategoryStyle = useMemo( - () => ({ - borderRadius: token.borderRadius, - border: `1px solid ${token.colorBorderSecondary}`, - background: token.colorFillQuaternary, - padding: "8px 10px", - minHeight: 68, - }), - [token.borderRadius, token.colorBorderSecondary, token.colorFillQuaternary], - ); - const tokenListStyle = useMemo( - () => ({ - display: "flex", - flexWrap: "wrap", - gap: 6, - marginTop: 6, - justifyContent: "center", - }), - [], - ); - const compactHelperCategoryStyle = useMemo( - () => ({ - padding: "6px", - minHeight: 52, - }), - [], - ); - const compactHelperTokenListStyle = useMemo( - () => ({ - ...tokenListStyle, - justifyContent: "center", - marginTop: 4, - gap: 4, - }), - [tokenListStyle], - ); - const referenceGridStyle = useMemo( - () => ({ - display: "grid", - // Keep references dense while predictable: 4 columns on desktop, 3/2 on medium widths, 1 on mobile. - gridTemplateColumns: screens.lg || screens.xl || screens.xxl - ? "repeat(4, minmax(0, 1fr))" - : screens.md - ? "repeat(3, minmax(0, 1fr))" - : screens.sm - ? "repeat(2, minmax(0, 1fr))" - : "repeat(1, minmax(0, 1fr))", - gap: 6, - }), - [screens.lg, screens.md, screens.sm, screens.xl, screens.xxl], - ); - const isDesktopLayout = Boolean(screens.lg || screens.xl || screens.xxl); - const isDesktopOperatorPanel = isDesktopLayout; - const showInlineOperatorPanel = Boolean(isDesktopOperatorPanel && !operatorPanelCollapsed); - const codeMirrorTheme = useMemo(() => { - const bgLuminance = resolveColorLuminance(token.colorBgContainer); - const textLuminance = resolveColorLuminance(token.colorText); - const isDark = bgLuminance != null ? bgLuminance < 0.5 : (textLuminance ?? 0) > 0.6; - // Use Ant warning background tokens so selection follows the theme palette, - // but stays muted enough for multiline editing in dark mode. - const selectionColor = isDark ? token.colorWarningBg : token.colorWarningBgHover; - const selectionMatchColor = isDark ? "rgba(250, 173, 20, 0.24)" : "rgba(250, 173, 20, 0.18)"; - const activeLineColor = isDark ? "rgba(250, 173, 20, 0.02)" : "rgba(22, 119, 255, 0.04)"; - const activeLineGutterColor = isDark ? "rgba(250, 173, 20, 0.04)" : "rgba(22, 119, 255, 0.06)"; - // Force editor foreground/background directly from design tokens so formula JSON remains - // readable in both light and dark themes regardless of global CSS inheritance. - return EditorView.theme( - { - "&": { - backgroundColor: token.colorBgContainer, - color: token.colorText, - borderRadius: token.borderRadius, - border: `1px solid ${token.colorBorder}`, - }, - "&.cm-editor": { - backgroundColor: token.colorBgContainer, - color: token.colorText, - }, - "& .cm-scroller": { - backgroundColor: token.colorBgContainer, - color: token.colorText, - }, - "&.cm-focused": { - outline: `1px solid ${token.colorPrimaryBorderHover}`, - }, - ".cm-scroller": { - fontFamily: token.fontFamilyCode || "monospace", - }, - ".cm-content, .cm-line": { - color: token.colorText, - caretColor: token.colorText, - }, - ".cm-cursor, .cm-dropCursor": { - borderLeftColor: token.colorText, - }, - // Keep bracket feedback enabled (standard editor behavior) but align it to amber theme. - ".cm-matchingBracket": { - backgroundColor: `${selectionMatchColor} !important`, - color: token.colorText, - outline: `1px solid ${token.colorWarningBorder}`, - borderRadius: 2, - }, - ".cm-nonmatchingBracket": { - backgroundColor: "transparent !important", - color: token.colorError, - outline: `1px solid ${token.colorErrorBorder}`, - borderRadius: 2, - }, - // Keep selection/search matches enabled (standard behavior), but use the same amber family. - ".cm-content .cm-selectionMatch, .cm-content .cm-searchMatch, .cm-content .cm-searchMatch-selected": { - backgroundColor: `${selectionMatchColor} !important`, - outline: `1px solid ${token.colorWarningBorder}`, - border: "none !important", - borderRadius: 2, - boxShadow: "none !important", - }, - ".cm-gutters": { - backgroundColor: token.colorBgElevated, - color: token.colorTextTertiary, - borderRight: `1px solid ${token.colorBorderSecondary}`, - }, - ".cm-activeLine": { - backgroundColor: activeLineColor, - }, - ".cm-activeLineGutter": { - backgroundColor: activeLineGutterColor, - }, - ".cm-selectionLayer": { - mixBlendMode: "normal", - }, - // Force one consistent drawn selection color for both focused and blurred states. - ".cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground, &.cm-focused .cm-selectionBackground, &.cm-focused .cm-selectionLayer .cm-selectionBackground": { - backgroundColor: `${selectionColor} !important`, - borderRadius: 2, - }, - // Keep native browser selection transparent so it doesn't override with platform colors. - ".cm-content ::selection, .cm-line ::selection, .cm-line > span::selection, .cm-content *::selection": { - backgroundColor: "transparent !important", - }, - }, - { dark: isDark }, - ); - }, [ - token.borderRadius, - token.colorBgContainer, - token.colorBgElevated, - token.colorBorder, - token.colorBorderSecondary, - token.colorError, - token.colorErrorBorder, - token.colorPrimaryBorderHover, - token.colorText, - token.colorTextTertiary, - token.colorWarningBorder, - token.colorWarningBg, - token.colorWarningBgHover, - token.fontFamilyCode, - ]); - useEffect(() => { - if (!isDesktopOperatorPanel) { - setOperatorPanelCollapsed(true); - return; - } - setOperatorPanelCollapsed(false); - }, [isDesktopOperatorPanel, derivedModalOpen]); - - const derivedFields = useGetDerivedFields(selectedEntityType); - const configuredFields = useGetFields(selectedEntityType); - const setDerivedField = useSetDerivedField(selectedEntityType); - const deleteDerivedField = useDeleteDerivedField(selectedEntityType); - const previewDerivedField = usePreviewDerivedField(selectedEntityType); - const expressionJsonValue = Form.useWatch("expression_json", derivedForm) as string | undefined; - const derivedKeyValue = ((Form.useWatch("key", derivedForm) as string | undefined) || "").trim(); - // Show the concrete API/template path for the currently typed key to remove - // ambiguity between formula operator names and field output identifiers. - const derivedKeyPath = useMemo( - () => (derivedKeyValue ? `derived.${derivedKeyValue}` : "derived."), - [derivedKeyValue], - ); - const keyLooksLikeReservedToken = useMemo( - () => RESERVED_DERIVED_KEY_NAMES.has(derivedKeyValue), - [derivedKeyValue], - ); - - const sampleValuesPlaceholder = SAMPLE_VALUE_PLACEHOLDERS[selectedEntityType]; - - const labeledField = (labelKey: string, tooltipKey: string) => ( - - {t(labelKey)} - - - - - ); - - const referenceOptions = useMemo(() => { - const extraReferences = (configuredFields.data || []).map((field) => `extra.${field.key}`); - // Suggest both built-in fields and configured extra fields so users can compose formulas - // without memorizing the exact reference syntax for each entity. - return [...new Set([...BUILTIN_REFERENCE_SUGGESTIONS[selectedEntityType], ...extraReferences])]; - }, [configuredFields.data, selectedEntityType]); - const compactReferenceOptions = useMemo( - () => - referenceOptions.map((reference) => ({ - value: reference, - label: `{${reference}}`, - })), - [referenceOptions], - ); - const helperByName = useMemo( - () => Object.fromEntries(FORMULA_HELPERS.map((helper) => [helper.name, helper] as const)), - [], - ); - const operatorGroups = useMemo( - () => - JSON_LOGIC_OPERATOR_GROUPS.map((group) => ({ - ...group, - label: t(`settings.complex_fields.formula.token_categories.${group.key}`), - })), - [t], - ); - const helperGroups = useMemo( - () => - FORMULA_HELPER_GROUPS.map((group) => ({ - ...group, - label: t(`settings.complex_fields.formula.token_categories.${group.key}`), - })), - [t], - ); - const helperGroupByKey = useMemo( - () => Object.fromEntries(helperGroups.map((group) => [group.key, group])), - [helperGroups], - ); - const referenceKindByName = useMemo(() => { - const map: Record = { - ...BUILTIN_REFERENCE_KIND_HINTS[selectedEntityType], - }; - - (configuredFields.data || []).forEach((field) => { - const fieldKind: ReferenceValueKind = (() => { - switch (field.field_type) { - case FieldType.integer: - case FieldType.float: - return "number"; - case FieldType.datetime: - return "datetime"; - case FieldType.boolean: - return "boolean"; - case FieldType.integer_range: - case FieldType.float_range: - return "range"; - case FieldType.text: - case FieldType.choice: - return "text"; - default: - return "unknown"; - } - })(); - map[`extra.${field.key}`] = fieldKind; - }); - - return map; - }, [configuredFields.data, selectedEntityType]); - const getHelperReferenceCount = (helper: FormulaHelperDefinition): number => { - if (helper.insert_mode === "none") { - return 0; - } - return helper.reference_count ?? 1; - }; - const helperAllowsReferenceKind = (helper: FormulaHelperDefinition, referenceKind: ReferenceValueKind): boolean => { - const requiredKind = helper.reference_kind ?? "any"; - if (requiredKind === "any") { - return true; - } - return referenceKind === requiredKind; - }; - const pendingHelperDefinition = useMemo(() => { - if (!pendingJsonHelperInsert) { - return null; - } - return helperByName[pendingJsonHelperInsert.helperName] || null; - }, [helperByName, pendingJsonHelperInsert]); - const getHelperDisabledReason = (helper: FormulaHelperDefinition): string | null => { - if (helper.insert_mode === "none") { - return null; - } - - const requiredRefCount = getHelperReferenceCount(helper); - const compatibleReferences = referenceOptions.filter((reference) => - helperAllowsReferenceKind(helper, referenceKindByName[reference] || "unknown"), - ); - if (compatibleReferences.length < requiredRefCount) { - return t("settings.complex_fields.formula.json_builder.helper_unavailable_reason", { helper: helper.name }); - } - - // When the user already picked reference #1 for a pending helper, temporarily disable helper - // tokens that can't accept that selected reference kind. Clearing/completing pending insert - // resets all helper tokens back to normal. - if (pendingJsonHelperInsert?.selectedReferences.length) { - const selectedKind = referenceKindByName[pendingJsonHelperInsert.selectedReferences[0]] || "unknown"; - if (!helperAllowsReferenceKind(helper, selectedKind)) { - return t("settings.complex_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); - } - } - - return null; - }; - const isReferenceCompatibleWithPendingHelper = (reference: string): boolean => { - if (!pendingHelperDefinition) { - return true; - } - const referenceKind = referenceKindByName[reference] || "unknown"; - return helperAllowsReferenceKind(pendingHelperDefinition, referenceKind); - }; - const buildHelperPlaceholderArguments = (helper: FormulaHelperDefinition): Array<{ var: string }> => { - const referenceCount = getHelperReferenceCount(helper); - if (referenceCount <= 0) { - return []; - } - if (referenceCount === 1) { - return [{ var: "value" }]; - } - if (referenceCount === 2) { - return [{ var: "start" }, { var: "end" }]; - } - return Array.from({ length: referenceCount }, (_, index) => ({ var: `arg_${index + 1}` })); - }; - const helperTokenGridStyle = useMemo( - () => ({ - display: "grid", - // Desktop uses a dedicated stacked helper layout; this fallback handles narrower widths. - gridTemplateColumns: screens.md || screens.sm ? "repeat(2, minmax(0, 1fr))" : "repeat(1, minmax(0, 1fr))", - gap: 8, - alignItems: "start", - }), - [screens.md, screens.sm], - ); - const renderTokenCategory = ( - key: string, - label: string, - tokens: ReactNode, - style?: CSSProperties, - tokenContainerStyle?: CSSProperties, - ) => ( -
- - {label} - -
{tokens}
-
- ); - const renderOperatorTokenGroups = (interactive: boolean) => ( -
- {operatorGroups.map((group) => { - const compactTitle = - group.key === "logical" - ? ( - <> - {t("settings.complex_fields.formula.json_builder.operator_compact.logical_top")} -
- {t("settings.complex_fields.formula.json_builder.operator_compact.logical_bottom")} - - ) - : group.key === "comparison" - ? t("settings.complex_fields.formula.json_builder.operator_compact.comparison") - : t("settings.complex_fields.formula.json_builder.operator_compact.math"); - const operatorGridColumns = group.key === "logical" ? "repeat(2, max-content)" : "repeat(3, max-content)"; - const labelColumnWidth = group.key === "logical" ? 90 : 78; - return ( -
-
- {group.operators.map((operator) => ( - (() => { - const tokenId = `operator-${group.key}-${operator}`; - const isHovered = hoveredTokenId === tokenId; - return ( - setHoveredTokenId(tokenId) : undefined} - onMouseLeave={interactive ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} - onClick={interactive ? () => insertExpressionJsonOperator(operator) : undefined} - > - {operator} - - ); - })() - ))} -
- - - {compactTitle} - - -
- ); - })} -
- ); - const renderHelperTokenCategory = ( - groupKey: string, - interactive: boolean, - compact = false, - ) => { - const group = helperGroupByKey[groupKey]; - if (!group || group.helpers.length === 0) { - return null; - } - return renderTokenCategory( - group.key, - group.label, - group.helpers.map((helper) => { - const disabledReason = interactive ? getHelperDisabledReason(helper) : null; - const tokenId = `helper-${helper.name}`; - const isHovered = hoveredTokenId === tokenId; - const helperToken = ( - setHoveredTokenId(tokenId) : undefined} - onMouseLeave={interactive && !disabledReason ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} - onClick={interactive && !disabledReason ? () => insertExpressionJsonHelper(helper) : undefined} - > - {helper.name} - - ); - return ( - - {helperToken} - - ); - }), - compact ? compactHelperCategoryStyle : undefined, - compact ? compactHelperTokenListStyle : undefined, - ); - }; - const renderHelperTokenGroups = (interactive: boolean) => { - if (isDesktopLayout) { - return ( -
- {HELPER_DESKTOP_COLUMN_LAYOUT.map((column) => ( -
- {renderHelperTokenCategory(column.top, interactive)} - {column.bottom ? renderHelperTokenCategory(column.bottom, interactive, true) : null} -
- ))} -
- ); - } - return ( -
- {helperGroups.map((group) => renderHelperTokenCategory(group.key, interactive))} -
- ); - }; - - const missingCustomReferencesByDerivedField = useMemo(() => { - const availableCustomFieldKeys = new Set((configuredFields.data || []).map((field) => field.key)); - const missingMap: Record = {}; - - (derivedFields.data || []).forEach((derivedField) => { - const missingReferences = getExtraFieldReferences(derivedField.expression_json || undefined).filter( - (reference) => !availableCustomFieldKeys.has(reference), - ); - if (missingReferences.length > 0) { - missingMap[derivedField.key] = missingReferences; - } - }); - - return missingMap; - }, [configuredFields.data, derivedFields.data]); - - const hasBrokenFormulaDependencies = useMemo( - () => Object.keys(missingCustomReferencesByDerivedField).length > 0, - [missingCustomReferencesByDerivedField], - ); - - const openCreateDerived = () => { - setEditingDerivedKey(null); - setPreviewText(null); - setPreviewReferences([]); - setResultTypeMismatchHint(null); - derivedForm.resetFields(); - derivedForm.setFieldsValue({ - key: "", - name: "", - description: "", - result_type: DerivedFieldType.number, - surfaces: [ComplexFieldSurface.show], - allow_list_column_toggle: false, - include_in_api: false, - expression_json: "", - sample_values: "{}", - }); - setDerivedModalOpen(true); - }; - - const openEditDerived = (record: DerivedField) => { - setEditingDerivedKey(record.key); - setPreviewText(null); - setPreviewReferences([]); - setResultTypeMismatchHint(null); - derivedForm.setFieldsValue({ - key: record.key, - name: record.name, - description: record.description || "", - result_type: record.result_type, - surfaces: record.surfaces, - allow_list_column_toggle: record.allow_list_column_toggle, - include_in_api: record.include_in_api ?? false, - expression_json: record.expression_json ? JSON.stringify(record.expression_json, null, 2) : "", - sample_values: "{}", - }); - setDerivedModalOpen(true); - }; - - const closeDerivedModal = () => { - setDerivedModalOpen(false); - setEditingDerivedKey(null); - setPreviewText(null); - setPreviewReferences([]); - setResultTypeMismatchHint(null); - setPendingJsonHelperInsert(null); - expressionJsonSelectionRef.current = { from: 0, to: 0 }; - derivedForm.resetFields(); - }; - - const insertExpressionJsonSnippet = (snippet: string) => { - const currentValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; - const hasEditor = expressionJsonEditorRef.current !== null; - if (!hasEditor) { - const prefix = currentValue.trim() === "" ? "" : "\n"; - derivedForm.setFieldValue("expression_json", `${currentValue}${prefix}${snippet}`); - return; - } - - const start = expressionJsonSelectionRef.current.from; - const end = expressionJsonSelectionRef.current.to; - const nextValue = `${currentValue.slice(0, start)}${snippet}${currentValue.slice(end)}`; - const nextCursor = start + snippet.length; - derivedForm.setFieldValue("expression_json", nextValue); - expressionJsonSelectionRef.current = { from: nextCursor, to: nextCursor }; - - requestAnimationFrame(() => { - const editor = expressionJsonEditorRef.current; - if (!editor) { - return; - } - editor.focus(); - editor.dispatch({ - selection: { anchor: nextCursor, head: nextCursor }, - scrollIntoView: true, - }); - }); - }; - - const insertExpressionJsonReference = (reference: string) => { - if (!pendingHelperDefinition) { - insertExpressionJsonSnippet(JSON.stringify({ var: reference }, null, 2)); - return; - } - - if (!isReferenceCompatibleWithPendingHelper(reference)) { - messageApi.warning( - t("settings.complex_fields.formula.json_builder.reference_incompatible_reason", { - helper: pendingHelperDefinition.name, - }), - ); - return; - } - - const pendingState = pendingJsonHelperInsert; - if (!pendingState) { - return; - } - const requiredReferenceCount = getHelperReferenceCount(pendingHelperDefinition); - const selectedReferences = [...pendingState.selectedReferences, reference]; - if (selectedReferences.length < requiredReferenceCount) { - setPendingJsonHelperInsert({ - helperName: pendingHelperDefinition.name, - selectedReferences, - }); - return; - } - - const snippet = { - [pendingHelperDefinition.name]: selectedReferences - .slice(0, requiredReferenceCount) - .map((selectedReference) => ({ var: selectedReference })), - }; - // Insert ready-to-parse JSON Logic objects so users can build expressions without memorizing - // raw AST syntax. - insertExpressionJsonSnippet(JSON.stringify(snippet, null, 2)); - setPendingJsonHelperInsert(null); - }; - - const insertExpressionJsonHelper = (helper: FormulaHelperDefinition) => { - if (helper.insert_mode === "none") { - insertExpressionJsonSnippet(JSON.stringify({ [helper.name]: [] }, null, 2)); - setPendingJsonHelperInsert(null); - return; - } - const disabledReason = getHelperDisabledReason(helper); - if (disabledReason) { - messageApi.warning(disabledReason); - return; - } - // Keep helper insertion staged until required reference tokens are selected, so helpers with - // multiple reference operands (for example days_between/hours_between) can be assembled safely. - setPendingJsonHelperInsert({ helperName: helper.name, selectedReferences: [] }); - messageApi.info( - t("settings.complex_fields.formula.json_builder.pending_helper", { - helper: helper.name, - selected: 0, - total: getHelperReferenceCount(helper), - }), - ); - }; - - const insertPendingHelperWithoutReferences = () => { - if (!pendingHelperDefinition) { - return; - } - const placeholderSnippet = { - [pendingHelperDefinition.name]: buildHelperPlaceholderArguments(pendingHelperDefinition), - }; - insertExpressionJsonSnippet(JSON.stringify(placeholderSnippet, null, 2)); - setPendingJsonHelperInsert(null); - }; - const cancelPendingHelperInsert = () => { - setPendingJsonHelperInsert(null); - }; - - const insertExpressionJsonOperator = (operator: string) => { - const snippet = JSON_LOGIC_OPERATOR_SNIPPETS[operator]; - if (!snippet) { - return; - } - insertExpressionJsonSnippet(snippet); - setPendingJsonHelperInsert(null); - }; - - const formatExpressionJson = async () => { - try { - const currentValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; - const parsed = parseExpressionJson(currentValue); - if (!parsed) { - setResultTypeMismatchHint(null); - return; - } - const selectedResultType = derivedForm.getFieldValue("result_type") as DerivedFieldType | undefined; - const inferredType = toDerivedFieldType(inferExpressionJsonType(parsed)); - if (selectedResultType && inferredType && inferredType !== selectedResultType) { - setResultTypeMismatchHint(inferredType); - } else { - setResultTypeMismatchHint(null); - } - derivedForm.setFieldValue("expression_json", JSON.stringify(parsed, null, 2)); - messageApi.success(t("settings.complex_fields.formula.json_builder.formatted")); - } catch (errInfo) { - if (errInfo instanceof Error) { - messageApi.error(errInfo.message); - } - } - }; - - const saveDerived = async () => { - try { - const values = await derivedForm.validateFields(); - const key = editingDerivedKey || values.key; - const expressionJson = parseExpressionJson(values.expression_json); - if (!expressionJson) { - throw new Error(t("settings.complex_fields.formula.expression_json_required")); - } - if (expressionJson) { - const inferredType = toDerivedFieldType(inferExpressionJsonType(expressionJson)); - if (inferredType && inferredType !== values.result_type) { - setResultTypeMismatchHint(inferredType); - } else { - setResultTypeMismatchHint(null); - } - } else { - setResultTypeMismatchHint(null); - } - - await setDerivedField.mutateAsync({ - key, - params: { - name: values.name, - description: values.description || undefined, - result_type: values.result_type, - expression_json: expressionJson, - surfaces: values.surfaces, - allow_list_column_toggle: values.allow_list_column_toggle, - include_in_api: values.include_in_api ?? false, - }, - }); - - messageApi.success( - t(editingDerivedKey ? "settings.complex_fields.formula.messages.updated" : "settings.complex_fields.formula.messages.created", { - name: values.name, - }), - ); - closeDerivedModal(); - } catch (errInfo) { - if (errInfo instanceof Error) { - messageApi.error(errInfo.message); - } - } - }; - - const previewDerived = async () => { - try { - const values = await derivedForm.validateFields(["expression_json", "sample_values"]); - const sampleValues = parseSampleValues(values.sample_values); - const expressionJson = parseExpressionJson(values.expression_json); - if (!expressionJson) { - throw new Error(t("settings.complex_fields.formula.expression_json_required")); - } - // Preview uses sample JSON only as a sandbox for validating formulas before they are exposed - // on show/list/template surfaces. - const preview = await previewDerivedField.mutateAsync({ - expression_json: expressionJson, - sample_values: sampleValues, - }); - - setPreviewText(formatPreviewValue(preview.result)); - setPreviewReferences(preview.references); - } catch (errInfo) { - if (errInfo instanceof Error) { - messageApi.error(errInfo.message); - } - } - }; - - const removeDerived = async (record: DerivedField) => { - try { - await deleteDerivedField.mutateAsync(record.key); - messageApi.success(t("settings.complex_fields.formula.messages.deleted", { name: record.name })); - } catch (errInfo) { - if (errInfo instanceof Error) { - messageApi.error(errInfo.message); - } - } - }; - - const derivedColumns: ColumnType[] = [ - { - title: t("settings.complex_fields.formula.columns.key"), - dataIndex: "key", - key: "key", - width: "12%", - }, - { - title: t("settings.complex_fields.formula.columns.name"), - dataIndex: "name", - key: "name", - width: "16%", - }, - { - title: t("settings.complex_fields.formula.columns.result_type"), - dataIndex: "result_type", - key: "result_type", - width: "10%", - render: (value: DerivedFieldType) => t(`settings.complex_fields.formula.types.${value}`), - }, - { - title: t("settings.complex_fields.formula.columns.expression"), - dataIndex: "expression_json", - key: "expression", - width: "30%", - render: (_value: Record | undefined, record) => { - const expressionValue = record.expression_json ? JSON.stringify(record.expression_json) : ""; - const missingReferences = missingCustomReferencesByDerivedField[record.key] || []; - return ( - - - {expressionValue} - - {missingReferences.length > 0 && ( - - {t("settings.complex_fields.formula.missing_references", { - references: missingReferences.join(", "), - })} - - )} - - ); - }, - }, - { - title: t("settings.complex_fields.formula.columns.surfaces"), - dataIndex: "surfaces", - key: "surfaces", - width: "16%", - render: (surfaces: string[]) => ( - - {surfaces.map((surface) => ( - {t(`settings.complex_fields.surfaces.${surface}`)} - ))} - - ), - }, - { - title: t("settings.complex_fields.formula.columns.include_in_api"), - dataIndex: "include_in_api", - key: "include_in_api", - width: "10%", - render: (value: boolean) => (value ? API : {t("no")}), - }, - { - title: "", - key: "operation", - width: "16%", - render: (_: unknown, record) => ( - - - removeDerived(record)} - okText={t("buttons.delete")} - cancelText={t("buttons.cancel")} - > - - - - ), - }, - ]; - - const previewSummary = useMemo(() => { - if (previewText == null) { - return null; - } - - const referencesText = - previewReferences.length > 0 - ? t("settings.complex_fields.formula.preview.references_used", { - references: previewReferences.join(", "), - }) - : t("settings.complex_fields.formula.preview.no_references"); - - return ( - - {t("settings.complex_fields.formula.preview.result_label")} {previewText} -
- {referencesText} -
- ); - }, [previewReferences, previewText, t]); - const pendingHelperHint = useMemo(() => { - if (!pendingHelperDefinition || !pendingJsonHelperInsert) { - return null; - } - const selected = pendingJsonHelperInsert.selectedReferences.length; - const total = getHelperReferenceCount(pendingHelperDefinition); - return { - helper: pendingHelperDefinition.name, - selected, - total, - }; - }, [pendingHelperDefinition, pendingJsonHelperInsert]); - return ( - <> - - - - {t("settings.complex_fields.formula.header")}: {niceName} - - - - - - {t("settings.complex_fields.help_links.formula")} - - - - - {t("settings.complex_fields.formula.intro")} - - - {t("settings.complex_fields.formula.evaluation_model_help")} - - {hasBrokenFormulaDependencies && ( - - {t("settings.complex_fields.formula.missing_references_intro")} - - )} - - {t("settings.complex_fields.available_functions.value")} - -
- ), - }} - onRow={(record) => { - const hasMissingReferences = (missingCustomReferencesByDerivedField[record.key] || []).length > 0; - if (!hasMissingReferences) { - return {}; - } - return { - style: { - backgroundColor: token.colorErrorBg, - }, - }; - }} - rowKey="key" - /> - - - - - {t("settings.complex_fields.formula.key_usage_help")}: {derivedKeyPath} - - {keyLooksLikeReservedToken && ( - - - {t("settings.complex_fields.formula.key_reserved_hint", { key: derivedKeyValue })} - - )} - - )} - rules={[ - { required: true, min: 1, max: 64, pattern: /^[a-z0-9_]+$/ }, - { - validator: async (_, value) => { - if (!editingDerivedKey && derivedFields.data?.some((field) => field.key === value)) { - throw new Error(t("settings.extra_fields.non_unique_key_error")); - } - }, - }, - ]} - > - - - - - - - - - - - - - {/* Keep Display In aligned with Result Type on desktop while preserving form order when stacked. */} - - - - {t("settings.complex_fields.formula.columns.result_type")} - {resultTypeMismatchHint && ( - <> - - - - - - )} - - )} - name="result_type" - rules={[{ required: true }]} - > - ({ - label: t(`settings.complex_fields.surfaces.${surface}`), - value: surface, - }))} - onChange={(selected: string[]) => { - if (!selected.includes(ComplexFieldSurface.list)) { - derivedForm.setFieldValue("allow_list_column_toggle", false); - } - }} - /> - - - {({ getFieldValue }) => { - const selectedSurfaces = (getFieldValue("surfaces") as string[] | undefined) || []; - const listEnabled = selectedSurfaces.includes(ComplexFieldSurface.list); - if (!listEnabled) { - return null; - } - - return ( - - - - - - {t("settings.complex_fields.formula.allow_list_column_toggle_inline", { entity: niceName })} - - - ); - }} - - - - - - - - - {labeledField( - "settings.complex_fields.formula.columns.expression_json", - "settings.complex_fields.formula.tooltips.expression_json", - )} - - {t("settings.complex_fields.help_links.formula_json")} - - - - - {t("settings.complex_fields.formula.expression_json_help")} - - - {t("settings.complex_fields.formula.expression_json_example")} - - - } - name="expression_json" - trigger="onChange" - getValueFromEvent={(value: string) => value} - rules={[ - { - validator: async (_, value) => { - const parsed = parseExpressionJson(value); - if (!parsed) { - throw new Error(t("settings.complex_fields.formula.expression_json_required")); - } - }, - }, - ]} - > -
- {isDesktopOperatorPanel && ( - - - - - -
- {showInlineOperatorPanel && ( -
- - {t("settings.complex_fields.formula.token_sections.operators")} - -
{renderOperatorTokenGroups(true)}
-
- )} - - -
- {/* Show helper/operators before references so helper-first insertion flow is visually guided. */} - - - - - {t("settings.complex_fields.formula.json_builder.operators_title")} - - - {t("settings.complex_fields.help_links.formula_tokens")} - - - -
- -
- - - {t("settings.complex_fields.formula.token_sections.helper_functions")} - - {pendingHelperHint ? ( - - - {t("settings.complex_fields.formula.json_builder.pending_helper_prefix")} - - - {pendingHelperHint.helper} - - - {t("settings.complex_fields.formula.json_builder.pending_helper_count", { - selected: pendingHelperHint.selected, - total: pendingHelperHint.total, - })} - - - - - - ) : null} - -
{renderHelperTokenGroups(true)}
-
-
- - {t("settings.complex_fields.formula.reference_picker.label")} - -
-
- {compactReferenceOptions.map((reference) => { - const referenceCompatible = isReferenceCompatibleWithPendingHelper(reference.value); - const isSelectedForPendingHelper = Boolean( - pendingJsonHelperInsert?.selectedReferences.includes(reference.value), - ); - const disabledReason = - !referenceCompatible && pendingHelperDefinition - ? t("settings.complex_fields.formula.json_builder.reference_incompatible_reason", { - helper: pendingHelperDefinition.name, - }) - : null; - const referenceToken = ( - setHoveredTokenId(`reference-${reference.value}`) : undefined} - onMouseLeave={!disabledReason ? () => setHoveredTokenId((current) => (current === `reference-${reference.value}` ? null : current)) : undefined} - onClick={!disabledReason ? () => insertExpressionJsonReference(reference.value) : undefined} - > - {reference.label} - - ); - // Keep a stable wrapper shape for all reference tokens so disabled/tooltip states - // do not cause reflow when helper compatibility changes. - const content = ( - - {referenceToken} - - ); - return ( -
- {content} -
- ); - })} -
-
-
-
-
- - {t("settings.complex_fields.formula.json_builder.click_to_insert_help")} - -
- { - parseSampleValues(value); - }, - }, - ]} - > - - - - - - - {labeledField( - "settings.complex_fields.formula.columns.include_in_api", - "settings.complex_fields.formula.tooltips.include_in_api", - )} - - - - - - {previewSummary} - - - - {contextHolder} - - ); -} diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index 7902b3e72..b8b12b62c 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -28,7 +28,7 @@ import { useParams } from "react-router"; import { DateTimePicker } from "../../components/dateTimePicker"; import { InputNumberRange } from "../../components/inputNumberRange"; import { getExtraFieldReferences } from "../../utils/formulaFields"; -import { FormulaFieldsSettings } from "./complexFieldsSettings"; +import { FormulaFieldsSettings } from "./formulaFieldsSettings"; import { EntityType, Field, diff --git a/client/src/pages/settings/formulaFieldsSettings.tsx b/client/src/pages/settings/formulaFieldsSettings.tsx new file mode 100644 index 000000000..952451e83 --- /dev/null +++ b/client/src/pages/settings/formulaFieldsSettings.tsx @@ -0,0 +1,2711 @@ +import { json } from "@codemirror/lang-json"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView, drawSelection } from "@codemirror/view"; +import CodeMirror from "@uiw/react-codemirror"; +import { tags as highlightTags } from "@lezer/highlight"; +import { + CloseCircleOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PlusOutlined, + QuestionCircleOutlined, + WarningOutlined, + CopyOutlined, +} from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { + Button, + Checkbox, + Col, + Divider, + Empty, + Flex, + Form, + Grid, + Input, + Modal, + Popconfirm, + Row, + Space, + Switch, + Table, + Tag, + Tooltip, + Typography, + message, + theme, +} from "antd"; +import { ColumnType } from "antd/es/table"; +import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link, useParams } from "react-router"; +import { + FORMULA_HELPER_GROUPS, + FORMULA_HELPERS, + FormulaHelperDefinition, + getExtraFieldReferences, + getFormulaReferencesFromJsonLogic, +} from "../../utils/formulaFields"; +import { + FormulaFieldSurface, + DerivedField, + DerivedFieldType, + EntityType, + FieldType, + Field, + useDeleteDerivedField, + useGetDerivedFields, + useGetFields, + usePreviewDerivedField, + useSetDerivedField, +} from "../../utils/queryFields"; + +const BUILTIN_REFERENCE_SUGGESTIONS: Record = { + vendor: ["id", "name", "registered", "comment"], + filament: ["id", "name", "material", "price", "density", "weight", "color_hex", "comment", "registered"], + spool: ["id", "weight", "remaining_weight", "used_weight", "price", "lot_nr", "comment", "registered"], +}; +const SAMPLE_VALUE_PLACEHOLDERS: Record = { + vendor: '{"name": "Example Vendor", "registered": "2026-02-28T10:15:00Z"}', + filament: '{"weight": 482.36, "material": "PLA", "registered": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}', + spool: '{"weight": 482.36, "remaining_weight": 225.12, "registered": "2026-02-28T10:15:00Z"}', +}; +const JSON_LOGIC_OPERATOR_GROUPS: Array<{ key: string; operators: string[] }> = [ + { key: "logical", operators: ["if", "and", "or", "!"] }, + { key: "comparison", operators: ["==", "!=", "<", "<=", ">", ">="] }, + { key: "arithmetic", operators: ["+", "-", "*", "/", "%", "floor"] }, +]; +// Layout constants for consistent spacing and sizing. +// OPERATOR_PANEL_WIDTH (244) and INLINE_OPERATOR_PANEL_HEIGHT (264) are paired to maintain +// visual balance: the operator panel height matches the JSON editor height when operators show inline. +// If adjusting one, keep them visually balanced so editor and operator box feel like one cohesive unit. +const OPERATOR_PANEL_WIDTH = 244; +const INLINE_OPERATOR_PANEL_HEIGHT = 264; +// Keep helper groups dense on desktop by pairing short groups under larger ones. +const HELPER_DESKTOP_COLUMN_LAYOUT: Array<{ top: string; bottom?: string }> = [ + { top: "math", bottom: "color" }, + { top: "text" }, + { top: "datetime" }, + { top: "dynamic", bottom: "date_diff" }, +]; +const JSON_LOGIC_OPERATOR_SNIPPETS: Record = { + if: '{\n "if": [\n {"var": "condition"},\n "then_value",\n "else_value"\n ]\n}', + and: '{\n "and": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + or: '{\n "or": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "!": '{\n "!": [\n {"var": "value"}\n ]\n}', + "==": '{\n "==": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "!=": '{\n "!=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "<": '{\n "<": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "<=": '{\n "<=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + ">": '{\n ">": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + ">=": '{\n ">=": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "+": '{\n "+": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "-": '{\n "-": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "*": '{\n "*": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "/": '{\n "/": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + "%": '{\n "%": [\n {"var": "left"},\n {"var": "right"}\n ]\n}', + floor: '{\n "floor": [\n {"var": "value"}\n ]\n}', +}; +const JSON_LOGIC_OPERATOR_OPERAND_COUNTS: Record = { + if: 3, + and: 2, + or: 2, + "!": 1, + "==": 2, + "!=": 2, + "<": 2, + "<=": 2, + ">": 2, + ">=": 2, + "+": 2, + "-": 2, + "*": 2, + "/": 2, + "%": 2, + floor: 1, +}; +// IF guided mode narrows the condition-builder to explicit comparison operators only. +const IF_CONDITION_COMPARISON_OPERATORS = new Set(["==", "!=", "<", "<=", ">", ">="]); +// Default scaffold shown immediately when IF is clicked on an empty editor. +const IF_SCAFFOLD_SNIPPET = '{\n "if": [\n {\n "Condition": []\n },\n "Then",\n "Else"\n ]\n}'; +const RESERVED_DERIVED_KEY_NAMES = new Set([ + ...JSON_LOGIC_OPERATOR_GROUPS.flatMap((group) => group.operators), + ...FORMULA_HELPERS.map((helper) => helper.name), +]); + +type ReferenceValueKind = "any" | "number" | "datetime" | "text" | "boolean" | "range" | "unknown"; +type PendingHelperOperand = { kind: "reference"; value: string } | { kind: "helper"; value: string }; +type PendingOperatorInsertState = { + operator: string; + selectedOperands: unknown[]; + requiredOperandCount: number; + // `if` can optionally guide users through a structured condition: + // compare operator -> left operand -> right operand -> then -> else. + pendingIfComparisonOperator?: string | null; + pendingIfComparisonOperands?: unknown[]; + replaceEditorOnComplete?: boolean; +}; +type PendingHelperInsertState = { + helperName: string; + selectedOperands: PendingHelperOperand[]; +}; +type PendingHelperHintState = { + helper: string; + selected: number; + total: number; + allowHelperOnly: boolean; + stepLabelKey?: string; +}; +type FormulaResultTypeHint = "number" | "text" | "boolean" | "unknown"; + +// Resolve the current IF guided-insert prompt step so the yellow helper hint can +// explicitly tell users what token click is expected next. +function getIfPendingStepLabelKey(state: PendingOperatorInsertState): string { + if (state.selectedOperands.length === 0) { + if (!state.pendingIfComparisonOperator) { + return "settings.formula_fields.formula.json_builder.if_step_condition_operator"; + } + const comparisonOperandCount = state.pendingIfComparisonOperands?.length || 0; + if (comparisonOperandCount === 0) { + return "settings.formula_fields.formula.json_builder.if_step_condition_left"; + } + return "settings.formula_fields.formula.json_builder.if_step_condition_right"; + } + if (state.selectedOperands.length === 1) { + return "settings.formula_fields.formula.json_builder.if_step_then"; + } + return "settings.formula_fields.formula.json_builder.if_step_else"; +} + +const BUILTIN_REFERENCE_KIND_HINTS: Record> = { + vendor: { + id: "number", + name: "text", + registered: "datetime", + comment: "text", + }, + filament: { + id: "number", + name: "text", + material: "text", + price: "number", + density: "number", + weight: "number", + color_hex: "text", + comment: "text", + registered: "datetime", + created_at: "datetime", + }, + spool: { + id: "number", + weight: "number", + remaining_weight: "number", + used_weight: "number", + price: "number", + lot_nr: "text", + comment: "text", + registered: "datetime", + created_at: "datetime", + }, +}; + +function resolveColorLuminance(color: string): number | null { + const normalized = color.trim().toLowerCase(); + + const hexMatch = normalized.match(/^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/); + if (hexMatch) { + const hex = hexMatch[1]; + const value = + hex.length === 3 || hex.length === 4 + ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + : hex.slice(0, 6); + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + } + + const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/); + if (!rgbMatch) { + return null; + } + const channels = rgbMatch[1] + .split(",") + .map((part) => part.trim()) + .slice(0, 3); + if (channels.length !== 3) { + return null; + } + + const toByte = (channel: string): number | null => { + if (channel.endsWith("%")) { + const percent = Number(channel.slice(0, -1)); + if (Number.isNaN(percent)) { + return null; + } + return Math.round((Math.max(0, Math.min(100, percent)) / 100) * 255); + } + const value = Number(channel); + if (Number.isNaN(value)) { + return null; + } + return Math.max(0, Math.min(255, value)); + }; + + const r = toByte(channels[0]); + const g = toByte(channels[1]); + const b = toByte(channels[2]); + if (r == null || g == null || b == null) { + return null; + } + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +function formatPreviewValue(value: string | number | boolean | null): string { + if (value === null) { + return "null"; + } + return `${value}`; +} + +// Validates and parses sample values JSON. Called during form validation. +// Throws localized error message if JSON is invalid (not an object). +function parseSampleValues(raw: string | undefined, errorTranslation?: string): Record { + if (!raw || raw.trim() === "") { + return {}; + } + + try { + const parsed = JSON.parse(raw); + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error(errorTranslation || "Sample values must be a JSON object."); + } + return parsed as Record; + } catch (e) { + if (e instanceof Error && (e.message === errorTranslation || !errorTranslation)) { + throw e; + } + // If JSON.parse failed, throw the user-friendly error + throw new Error(errorTranslation || "Sample values must be a JSON object."); + } +} + +// Validates and parses expression JSON. Called during form validation. +// Returns undefined if empty, throws localized error if invalid. +function parseExpressionJson(raw: string | undefined, errorTranslation?: string): Record | undefined { + if (!raw || raw.trim() === "") { + return undefined; + } + + try { + const parsed = JSON.parse(raw); + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error(errorTranslation || "Expression JSON must be a JSON object."); + } + return parsed as Record; + } catch (e) { + if (e instanceof Error && (e.message === errorTranslation || !errorTranslation)) { + throw e; + } + // If JSON.parse failed, throw the user-friendly error + throw new Error(errorTranslation || "Expression JSON must be a JSON object."); + } +} + +function hasReferencePath(sampleValues: Record, reference: string): boolean { + const parts = reference.split(".").filter((part) => part.length > 0); + if (parts.length === 0) { + return false; + } + + let current: unknown = sampleValues; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (current === null || typeof current !== "object" || Array.isArray(current)) { + return false; + } + const record = current as Record; + if (!(part in record)) { + return false; + } + current = record[part]; + } + + return true; +} + +// Ensure every detected reference path exists in sample JSON so preview can evaluate formulas +// immediately without requiring the user to hand-create nested keys. +function insertReferencePathIfMissing( + sampleValues: Record, + reference: string, + defaultValue: unknown, +): boolean { + const parts = reference.split(".").filter((part) => part.length > 0); + if (parts.length === 0) { + return false; + } + + let current: Record = sampleValues; + for (let index = 0; index < parts.length - 1; index += 1) { + const part = parts[index]; + const existing = current[part]; + if (existing === undefined) { + current[part] = {}; + current = current[part] as Record; + continue; + } + if (existing === null || typeof existing !== "object" || Array.isArray(existing)) { + return false; + } + current = existing as Record; + } + + const leaf = parts[parts.length - 1]; + if (leaf in current) { + return false; + } + current[leaf] = defaultValue; + return true; +} + +// Remove a previously auto-managed reference path when it no longer appears in the expression. +// This keeps sample JSON aligned with active refs and prunes empty parent objects afterwards. +function removeReferencePathIfPresent(sampleValues: Record, reference: string): boolean { + const parts = reference.split(".").filter((part) => part.length > 0); + if (parts.length === 0) { + return false; + } + + const parents: Array<{ record: Record; key: string }> = []; + let current: Record = sampleValues; + for (let index = 0; index < parts.length - 1; index += 1) { + const part = parts[index]; + const nextValue = current[part]; + if (nextValue === null || typeof nextValue !== "object" || Array.isArray(nextValue)) { + return false; + } + parents.push({ record: current, key: part }); + current = nextValue as Record; + } + + const leaf = parts[parts.length - 1]; + if (!(leaf in current)) { + return false; + } + delete current[leaf]; + + // Remove now-empty containers so deleted transient references do not leave dead paths behind. + for (let index = parents.length - 1; index >= 0; index -= 1) { + const { record, key } = parents[index]; + const value = record[key]; + if (value === null || typeof value !== "object" || Array.isArray(value)) { + break; + } + if (Object.keys(value as Record).length > 0) { + break; + } + delete record[key]; + } + return true; +} + +// Use bounded random numeric defaults so auto-scaffolded sample values feel realistic +// and avoid repeating the same constant during expression authoring. +function randomIntegerSampleValue(): number { + return Math.floor(Math.random() * 1001); +} + +function randomFloatSampleValue(): number { + return Number((Math.random() * 1000).toFixed(2)); +} + +// Randomize datetime sample times so date-diff previews surface fractional values by default. +function randomTwoDigitSampleValue(maxExclusive: number): string { + return Math.floor(Math.random() * maxExclusive).toString().padStart(2, "0"); +} + +function randomIsoDatetimeSampleValue(baseDate: string): string { + return baseDate + "T" + randomTwoDigitSampleValue(24) + ":" + randomTwoDigitSampleValue(60) + ":" + randomTwoDigitSampleValue(60) + "Z"; +} + +function randomOrderedIntegerRangeSampleValue(): [number, number] { + const first = randomIntegerSampleValue(); + const second = randomIntegerSampleValue(); + return first <= second ? [first, second] : [second, first]; +} + +function randomOrderedFloatRangeSampleValue(): [number, number] { + const first = randomFloatSampleValue(); + const second = randomFloatSampleValue(); + return first <= second ? [first, second] : [second, first]; +} + +function getSampleDefaultValue(kind: ReferenceValueKind, reference: string, configuredField?: Field): unknown { + // Custom extra-field defaults are type-driven so newly referenced fields get + // meaningful sample values immediately for preview runs. + if (configuredField) { + switch (configuredField.field_type) { + case FieldType.text: + return "Preview Text"; + case FieldType.integer: + return randomIntegerSampleValue(); + case FieldType.integer_range: + return randomOrderedIntegerRangeSampleValue(); + case FieldType.float: + return randomFloatSampleValue(); + case FieldType.float_range: + return randomOrderedFloatRangeSampleValue(); + case FieldType.datetime: + // ISO format preserves the user-requested timestamp/CET intent while remaining parser-safe. + return randomIsoDatetimeSampleValue("2019-05-01"); + case FieldType.boolean: + return true; + case FieldType.choice: + if (configuredField.multi_choice) { + return configuredField.choices?.slice(0, 2) ?? ["Spool", "Man"]; + } + return configuredField.choices?.[0] ?? "Spool"; + default: + return null; + } + } + + const referenceLeaf = reference.split(".").filter(Boolean).at(-1) || reference; + const normalizedLeaf = referenceLeaf.toLowerCase(); + + // Seed known semantic fields with practical defaults so preview works immediately + // without forcing users to hand-craft first-pass sample values. + if (normalizedLeaf.includes("color_hex") || normalizedLeaf.endsWith("_hex")) { + return "#FF00FF"; + } + + switch (kind) { + case "number": + return randomFloatSampleValue(); + case "boolean": + return false; + case "datetime": + return randomIsoDatetimeSampleValue("2026-01-01"); + case "text": + return "sample_text"; + case "range": + return randomOrderedFloatRangeSampleValue(); + default: + return null; + } +} + +function mergeTypeHints(typeHints: FormulaResultTypeHint[]): FormulaResultTypeHint { + const knownHints = typeHints.filter((typeHint) => typeHint !== "unknown"); + if (knownHints.length === 0) { + return "unknown"; + } + return knownHints.every((typeHint) => typeHint === knownHints[0]) ? knownHints[0] : "unknown"; +} + +function inferExpressionJsonType(node: unknown): FormulaResultTypeHint { + if (typeof node === "number") { + return "number"; + } + if (typeof node === "string") { + return "text"; + } + if (typeof node === "boolean") { + return "boolean"; + } + if (node === null || Array.isArray(node) || typeof node !== "object") { + return "unknown"; + } + + const entries = Object.entries(node as Record); + if (entries.length !== 1) { + return "unknown"; + } + + const [operator, rawArgs] = entries[0]; + const args = Array.isArray(rawArgs) ? rawArgs : [rawArgs]; + + if (operator === "var") { + return "unknown"; + } + + if (operator === "if") { + const branchHints: FormulaResultTypeHint[] = []; + for (let index = 1; index < args.length; index += 2) { + branchHints.push(inferExpressionJsonType(args[index])); + } + if (args.length % 2 === 0 && args.length > 0) { + branchHints.push(inferExpressionJsonType(args[args.length - 1])); + } + return mergeTypeHints(branchHints); + } + + if (operator === "coalesce") { + return mergeTypeHints(args.map((arg) => inferExpressionJsonType(arg))); + } + + if (["==", "!=", "<", "<=", ">", ">=", "!", "and", "or"].includes(operator)) { + return "boolean"; + } + + if ( + [ + "+", + "-", + "*", + "/", + "%", + "abs", + "min", + "max", + "round", + "year", + "month", + "day", + "hour", + "minute", + "second", + "timestamp", + "days_between", + "hours_between", + "hue_from_hex", + "length", + ].includes(operator) + ) { + return "number"; + } + + if (["date_only", "time_only", "today", "cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes(operator)) { + return "text"; + } + + return "unknown"; +} + +function toDerivedFieldType(typeHint: FormulaResultTypeHint): DerivedFieldType | null { + if (typeHint === "number") { + return DerivedFieldType.number; + } + if (typeHint === "text") { + return DerivedFieldType.text; + } + return null; +} + +export function FormulaFieldsSettings() { + const { entityType } = useParams<{ entityType: EntityType }>(); + const t = useTranslate(); + const { token } = theme.useToken(); + const screens = Grid.useBreakpoint(); + const [messageApi, contextHolder] = message.useMessage(); + const [derivedModalOpen, setDerivedModalOpen] = useState(false); + const [editingDerivedKey, setEditingDerivedKey] = useState(null); + const [previewText, setPreviewText] = useState(null); + const [previewErrorText, setPreviewErrorText] = useState(null); + const [pendingJsonHelperInsert, setPendingJsonHelperInsert] = useState(null); + const [pendingOperatorInsert, setPendingOperatorInsert] = useState(null); + const [operatorPanelCollapsed, setOperatorPanelCollapsed] = useState(false); + const [tokensPanelCollapsed, setTokensPanelCollapsed] = useState(false); + const [hoveredTokenId, setHoveredTokenId] = useState(null); + const [sampleValuesAutoUpdateEnabled, setSampleValuesAutoUpdateEnabled] = useState(true); + const [derivedForm] = Form.useForm(); + const expressionJsonEditorRef = useRef(null); + const expressionJsonSelectionRef = useRef<{ from: number; to: number }>({ from: 0, to: 0 }); + const expressionJsonProgrammaticValueRef = useRef(null); + const previewRequestRef = useRef(0); + // Track only auto-scaffolded sample references so we can safely prune stale transient + // keys without deleting user-authored sample keys. + const autoManagedSampleReferencesRef = useRef>(new Set()); + + const selectedEntityType = entityType as EntityType; + const niceName = t(`${selectedEntityType}.${selectedEntityType}`); + const sectionBodyStyle = { marginTop: 0, fontSize: token.fontSize, lineHeight: 1.7 }; + const tokenPanelStyle = useMemo( + () => ({ + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + padding: 10, + background: token.colorBgContainer, + }), + [token.colorBgContainer, token.colorBorderSecondary, token.borderRadiusLG], + ); + const tokenCategoryStyle = useMemo( + () => ({ + borderRadius: token.borderRadius, + border: `1px solid ${token.colorBorderSecondary}`, + background: token.colorFillQuaternary, + padding: "8px 10px", + minHeight: 68, + }), + [token.borderRadius, token.colorBorderSecondary, token.colorFillQuaternary], + ); + const tokenListStyle = useMemo( + () => ({ + display: "flex", + flexWrap: "wrap", + gap: 6, + marginTop: 6, + justifyContent: "center", + }), + [], + ); + const compactHelperCategoryStyle = useMemo( + () => ({ + padding: "6px", + minHeight: 52, + }), + [], + ); + const compactHelperTokenListStyle = useMemo( + () => ({ + ...tokenListStyle, + justifyContent: "center", + marginTop: 4, + gap: 4, + }), + [tokenListStyle], + ); + const referenceGridStyle = useMemo( + () => ({ + display: "grid", + // Keep references dense while predictable: 4 columns on desktop, 3/2 on medium widths, 1 on mobile. + gridTemplateColumns: screens.lg || screens.xl || screens.xxl + ? "repeat(4, minmax(0, 1fr))" + : screens.md + ? "repeat(3, minmax(0, 1fr))" + : screens.sm + ? "repeat(2, minmax(0, 1fr))" + : "repeat(1, minmax(0, 1fr))", + gap: 6, + }), + [screens.lg, screens.md, screens.sm, screens.xl, screens.xxl], + ); + const isDesktopLayout = Boolean(screens.lg || screens.xl || screens.xxl); + const isDesktopOperatorPanel = isDesktopLayout; + const showInlineOperatorPanel = Boolean(isDesktopOperatorPanel && !operatorPanelCollapsed); + const expressionEditorHeight = INLINE_OPERATOR_PANEL_HEIGHT; + // Keep JSON string tokens orange in both editors so references/values do not appear as errors. + const codeMirrorHighlightStyle = useMemo( + () => + HighlightStyle.define([ + { + tag: [highlightTags.string, highlightTags.special(highlightTags.string)], + color: token.colorWarningText, + }, + ]), + [token.colorWarningText], + ); + const codeMirrorSyntaxHighlight = useMemo( + () => syntaxHighlighting(codeMirrorHighlightStyle), + [codeMirrorHighlightStyle], + ); + const codeMirrorTheme = useMemo(() => { + const bgLuminance = resolveColorLuminance(token.colorBgContainer); + const textLuminance = resolveColorLuminance(token.colorText); + const isDark = bgLuminance != null ? bgLuminance < 0.5 : (textLuminance ?? 0) > 0.6; + // Use Ant warning background tokens so selection follows the theme palette, + // but stays muted enough for multiline editing in dark mode. + const selectionColor = isDark ? token.colorWarningBg : token.colorWarningBgHover; + const selectionMatchColor = isDark ? "rgba(250, 173, 20, 0.24)" : "rgba(250, 173, 20, 0.18)"; + const activeLineColor = isDark ? "rgba(250, 173, 20, 0.02)" : "rgba(22, 119, 255, 0.04)"; + const activeLineGutterColor = isDark ? "rgba(250, 173, 20, 0.04)" : "rgba(22, 119, 255, 0.06)"; + // Force editor foreground/background directly from design tokens so formula JSON remains + // readable in both light and dark themes regardless of global CSS inheritance. + return EditorView.theme( + { + "&": { + backgroundColor: token.colorBgContainer, + color: token.colorText, + borderRadius: token.borderRadius, + border: `1px solid ${token.colorBorder}`, + }, + "&.cm-editor": { + backgroundColor: token.colorBgContainer, + color: token.colorText, + }, + "& .cm-scroller": { + backgroundColor: token.colorBgContainer, + color: token.colorText, + }, + "&.cm-focused": { + outline: `1px solid ${token.colorPrimaryBorderHover}`, + }, + ".cm-scroller": { + fontFamily: token.fontFamilyCode || "monospace", + }, + ".cm-content, .cm-line": { + color: token.colorText, + caretColor: token.colorText, + }, + // Keep JSON string literals in warning/orange instead of red so valid string values + // don't read like errors in either expression or sample-value editors. + ".cm-string": { + color: `${token.colorWarningText} !important`, + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: token.colorText, + }, + // Keep bracket feedback enabled (standard editor behavior) but align it to amber theme. + ".cm-matchingBracket": { + backgroundColor: `${selectionMatchColor} !important`, + color: token.colorText, + outline: `1px solid ${token.colorWarningBorder}`, + borderRadius: 2, + }, + ".cm-nonmatchingBracket": { + backgroundColor: "transparent !important", + color: token.colorError, + outline: `1px solid ${token.colorErrorBorder}`, + borderRadius: 2, + }, + // Keep selection/search matches enabled (standard behavior), but use the same amber family. + ".cm-content .cm-selectionMatch, .cm-content .cm-searchMatch, .cm-content .cm-searchMatch-selected": { + backgroundColor: `${selectionMatchColor} !important`, + outline: `1px solid ${token.colorWarningBorder}`, + border: "none !important", + borderRadius: 2, + boxShadow: "none !important", + }, + ".cm-gutters": { + backgroundColor: token.colorBgElevated, + color: token.colorTextTertiary, + borderRight: `1px solid ${token.colorBorderSecondary}`, + }, + ".cm-activeLine": { + backgroundColor: activeLineColor, + }, + ".cm-activeLineGutter": { + backgroundColor: activeLineGutterColor, + }, + ".cm-selectionLayer": { + mixBlendMode: "normal", + }, + // Force one consistent drawn selection color for both focused and blurred states. + ".cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground, &.cm-focused .cm-selectionBackground, &.cm-focused .cm-selectionLayer .cm-selectionBackground": { + backgroundColor: `${selectionColor} !important`, + borderRadius: 2, + }, + // Keep native browser selection transparent so it doesn't override with platform colors. + ".cm-content ::selection, .cm-line ::selection, .cm-line > span::selection, .cm-content *::selection": { + backgroundColor: "transparent !important", + }, + }, + { dark: isDark }, + ); + }, [ + token.borderRadius, + token.colorBgContainer, + token.colorBgElevated, + token.colorBorder, + token.colorBorderSecondary, + token.colorError, + token.colorErrorBorder, + token.colorPrimaryBorderHover, + token.colorText, + token.colorTextTertiary, + token.colorWarningBorder, + token.colorWarningBg, + token.colorWarningBgHover, + token.fontFamilyCode, + ]); + const derivedFields = useGetDerivedFields(selectedEntityType); + const configuredFields = useGetFields(selectedEntityType); + const setDerivedField = useSetDerivedField(selectedEntityType); + const deleteDerivedField = useDeleteDerivedField(selectedEntityType); + const previewDerivedField = usePreviewDerivedField(selectedEntityType); + const expressionJsonValue = Form.useWatch("expression_json", derivedForm) as string | undefined; + const sampleValuesValue = Form.useWatch("sample_values", derivedForm) as string | undefined; + const derivedKeyValue = ((Form.useWatch("key", derivedForm) as string | undefined) || "").trim(); + // Show the concrete API/template path for the currently typed key to remove + // ambiguity between formula operator names and field output identifiers. + const derivedKeyPath = useMemo( + () => (derivedKeyValue ? `derived.${derivedKeyValue}` : "derived."), + [derivedKeyValue], + ); + const displaySurfaceOptions = useMemo( + () => [ + { value: FormulaFieldSurface.show, label: t("settings.formula_fields.formula.display_targets.show_pages") }, + { + value: FormulaFieldSurface.template, + label: t("settings.formula_fields.formula.display_targets.template_selections"), + }, + { value: FormulaFieldSurface.list, label: t("settings.formula_fields.formula.display_targets.tables") }, + ], + [t], + ); + const keyLooksLikeReservedToken = useMemo( + () => RESERVED_DERIVED_KEY_NAMES.has(derivedKeyValue), + [derivedKeyValue], + ); + + const sampleValuesPlaceholder = SAMPLE_VALUE_PLACEHOLDERS[selectedEntityType]; + + const labeledField = (labelKey: string, tooltipKey: string) => ( + + {t(labelKey)} + + + + + ); + + const referenceOptions = useMemo(() => { + const extraReferences = (configuredFields.data || []).map((field) => `extra.${field.key}`); + // Suggest both built-in fields and configured extra fields so users can compose formulas + // without memorizing the exact reference syntax for each entity. + return [...new Set([...BUILTIN_REFERENCE_SUGGESTIONS[selectedEntityType], ...extraReferences])]; + }, [configuredFields.data, selectedEntityType]); + const configuredFieldByReference = useMemo( + () => + Object.fromEntries( + (configuredFields.data || []).map((field) => [`extra.${field.key}`, field] as const), + ) as Record, + [configuredFields.data], + ); + const compactReferenceOptions = useMemo( + () => + referenceOptions.map((reference) => ({ + value: reference, + label: `{${reference}}`, + })), + [referenceOptions], + ); + // Keep parsed expression state explicit so reference syncing only mutates sample JSON + // when the editor content is valid JSON (invalid typing states should not generate keys). + const parsedExpressionJson = useMemo(() => { + try { + return parseExpressionJson(expressionJsonValue); + } catch { + return null; + } + }, [expressionJsonValue]); + + // Detect active var references from the current valid expression only. + const detectedExpressionReferences = useMemo(() => { + if (!parsedExpressionJson) { + return [] as string[]; + } + return getFormulaReferencesFromJsonLogic(parsedExpressionJson).filter((reference) => reference.trim().length > 0); + }, [parsedExpressionJson]); + const parsedSampleValues = useMemo(() => { + try { + return parseSampleValues(sampleValuesValue); + } catch { + return null; + } + }, [sampleValuesValue]); + const missingSampleValueReferences = useMemo(() => { + if (!parsedSampleValues) { + return [] as string[]; + } + return detectedExpressionReferences.filter((reference) => !hasReferencePath(parsedSampleValues, reference)); + }, [detectedExpressionReferences, parsedSampleValues]); + const hasValidSampleValues = parsedSampleValues !== null; + const helperByName = useMemo( + () => Object.fromEntries(FORMULA_HELPERS.map((helper) => [helper.name, helper] as const)), + [], + ); + const operatorGroups = useMemo( + () => + JSON_LOGIC_OPERATOR_GROUPS.map((group) => ({ + ...group, + label: t(`settings.formula_fields.formula.token_categories.${group.key}`), + })), + [t], + ); + const helperGroups = useMemo( + () => + FORMULA_HELPER_GROUPS.map((group) => ({ + ...group, + label: t(`settings.formula_fields.formula.token_categories.${group.key}`), + })), + [t], + ); + const helperGroupByKey = useMemo( + () => Object.fromEntries(helperGroups.map((group) => [group.key, group])), + [helperGroups], + ); + const referenceKindByName = useMemo(() => { + const map: Record = { + ...BUILTIN_REFERENCE_KIND_HINTS[selectedEntityType], + }; + + (configuredFields.data || []).forEach((field) => { + const fieldKind: ReferenceValueKind = (() => { + switch (field.field_type) { + case FieldType.integer: + case FieldType.float: + return "number"; + case FieldType.datetime: + return "datetime"; + case FieldType.boolean: + return "boolean"; + case FieldType.integer_range: + case FieldType.float_range: + return "range"; + case FieldType.text: + case FieldType.choice: + return "text"; + default: + return "unknown"; + } + })(); + map[`extra.${field.key}`] = fieldKind; + }); + + return map; + }, [configuredFields.data, selectedEntityType]); + const getHelperReferenceCount = (helper: FormulaHelperDefinition): number => { + if (helper.insert_mode === "none") { + return 0; + } + return helper.reference_count ?? 1; + }; + // Resolve how many operands an operator requires so the click-flow can collect + // references/helpers and insert complete JSON Logic snippets in one step. + const getOperatorOperandCount = (operator: string): number => { + return JSON_LOGIC_OPERATOR_OPERAND_COUNTS[operator] ?? 2; + }; + // `if` guided mode starts by collecting a comparison operator for the condition node. + const isAwaitingIfComparisonOperator = useMemo( + () => + pendingOperatorInsert?.operator === "if" && + pendingOperatorInsert.selectedOperands.length === 0 && + !pendingOperatorInsert.pendingIfComparisonOperator, + [pendingOperatorInsert], + ); + // While `if` is waiting for a comparison operator, shade out non-comparison operator tokens + // so click-flow remains deterministic and users are guided toward valid condition structure. + const isOperatorTokenTemporarilyDisabled = (operator: string): boolean => { + if (!isAwaitingIfComparisonOperator) { + return false; + } + return !IF_CONDITION_COMPARISON_OPERATORS.has(operator); + }; + const helperAllowsReferenceKind = (helper: FormulaHelperDefinition, referenceKind: ReferenceValueKind): boolean => { + const requiredKind = helper.reference_kind ?? "any"; + if (requiredKind === "any") { + return true; + } + return referenceKind === requiredKind; + }; + const pendingHelperDefinition = useMemo(() => { + if (!pendingJsonHelperInsert) { + return null; + } + return helperByName[pendingJsonHelperInsert.helperName] || null; + }, [helperByName, pendingJsonHelperInsert]); + const getHelperDisabledReason = (helper: FormulaHelperDefinition): string | null => { + // Keep date-diff pending mode intentionally narrow: only today() may act as the helper-side + // operand while reference picking handles datetime fields like created_at/extra.dry_date. + if (pendingHelperDefinition?.category === "date_diff" && helper.name !== "today") { + return t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); + } + + if (helper.insert_mode === "none") { + return null; + } + + const requiredRefCount = getHelperReferenceCount(helper); + const compatibleReferences = referenceOptions.filter((reference) => + helperAllowsReferenceKind(helper, referenceKindByName[reference] || "unknown"), + ); + // Date-diff helpers can still be composed with non-reference operands (for example today()), + // so keep them available even when matching reference count is below required placeholders. + const supportsNonReferenceOperands = helper.category === "date_diff"; + if (!supportsNonReferenceOperands && compatibleReferences.length < requiredRefCount) { + return t("settings.formula_fields.formula.json_builder.helper_unavailable_reason", { helper: helper.name }); + } + + // When the user already picked reference #1 for a pending helper, temporarily disable helper + // tokens that can't accept that selected reference kind. Clearing/completing pending insert + // resets all helper tokens back to normal. + if (pendingJsonHelperInsert?.selectedOperands.length) { + const selectedReference = pendingJsonHelperInsert.selectedOperands.find((operand) => operand.kind === "reference"); + if (!selectedReference) { + return null; + } + const selectedKind = referenceKindByName[selectedReference.value] || "unknown"; + if (!helperAllowsReferenceKind(helper, selectedKind)) { + return t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); + } + } + + return null; + }; + const isReferenceCompatibleWithPendingHelper = (reference: string): boolean => { + if (!pendingHelperDefinition) { + return true; + } + const referenceKind = referenceKindByName[reference] || "unknown"; + return helperAllowsReferenceKind(pendingHelperDefinition, referenceKind); + }; + const buildHelperPlaceholderArguments = (helper: FormulaHelperDefinition): Array<{ var: string }> => { + const referenceCount = getHelperReferenceCount(helper); + if (referenceCount <= 0) { + return []; + } + if (referenceCount === 1) { + return [{ var: "value" }]; + } + if (referenceCount === 2) { + return [{ var: "start" }, { var: "end" }]; + } + return Array.from({ length: referenceCount }, (_, index) => ({ var: `arg_${index + 1}` })); + }; + const helperTokenGridStyle = useMemo( + () => ({ + display: "grid", + // Desktop uses a custom stacked layout; this fallback keeps helper groups readable on smaller screens. + gridTemplateColumns: screens.md || screens.sm ? "repeat(2, minmax(0, 1fr))" : "repeat(1, minmax(0, 1fr))", + gap: 8, + alignItems: "start", + }), + [screens.md, screens.sm], + ); + + // ─── Token Rendering & Insertion Logic ─── + // This section manages the clickable token interface for building JSON expressions. + // Users can click operators, helpers, or field references to insert JSON snippets into the editor. + // + // Two insertion patterns: + // 1. Operators (logical, comparison, math): Single-stage insertion. Click operator → immediately insert + // complete snippet with placeholder operands (e.g., "+ 1 1" for addition). Fast for common operations. + // 2. Helpers (days_between, if_then_else, etc): Two-stage insertion. Click helper → modal opens to collect + // compatible references → insert complete helper with selected references. Prevents invalid combinations + // and provides realtime compatibility checking (e.g., "if_then_else" requires boolean condition). + // + // Field references (custom fields, entity properties) can be inserted at any point as operand placeholders. + const renderTokenCategory = ( + key: string, + label: string, + tokens: ReactNode, + style?: CSSProperties, + tokenContainerStyle?: CSSProperties, + ) => ( +
+ + {label} + +
{tokens}
+
+ ); + + // Operators: Renders logical (and/or), comparison (==/>/<), and math (+/-/*/) tokens in compact grids. + // Clicking an operator immediately inserts the JSON Logic snippet for that operator with placeholder + // operands. Disabled operators are grayed out (e.g., can't nest same operator recursively in some cases). + // Layout: Logical operators (2 cols), comparison (3 cols), math (3 cols) to fit JSON editor width. + const renderOperatorTokenGroups = (interactive: boolean) => ( + // Compact two-column operator cells keep JSON editor width while preserving quick-click operator insertion. +
+ {operatorGroups.map((group) => { + const compactTitle = + group.key === "logical" + ? ( + <> + {t("settings.formula_fields.formula.json_builder.operator_compact.logical_top")} +
+ {t("settings.formula_fields.formula.json_builder.operator_compact.logical_bottom")} + + ) + : group.key === "comparison" + ? t("settings.formula_fields.formula.json_builder.operator_compact.comparison") + : t("settings.formula_fields.formula.json_builder.operator_compact.math"); + const operatorGridColumns = group.key === "logical" ? "repeat(2, max-content)" : "repeat(3, max-content)"; + const labelColumnWidth = group.key === "logical" ? 90 : 78; + return ( +
+
+ {group.operators.map((operator) => ( + (() => { + const tokenId = `operator-${group.key}-${operator}`; + const isHovered = hoveredTokenId === tokenId; + const disabled = interactive ? isOperatorTokenTemporarilyDisabled(operator) : false; + return ( + setHoveredTokenId(tokenId) : undefined} + onMouseLeave={interactive && !disabled ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} + onClick={interactive && !disabled ? () => insertExpressionJsonOperator(operator) : undefined} + > + {operator} + + ); + })() + ))} +
+ + + {compactTitle} + + +
+ ); + })} +
+ ); + + // Helpers: Renders reusable helper functions grouped by category (date math, conditional, etc). + // Clicking a helper triggers the two-stage insertion flow: a modal collects which field references + // to include (e.g., "which spool attribute to check for days_between?"), then inserts a complete + // helper snippet with those references. Respects helper constraints: insert_mode (none/single/multiple), + // reference_count (how many fields the helper needs), value_kind (type checks for compatibility). + // Disabled helpers show tooltips explaining why (e.g., "no numeric fields available for math helper"). + const renderHelperTokenCategory = ( + groupKey: string, + interactive: boolean, + compact = false, + ) => { + const group = helperGroupByKey[groupKey]; + if (!group || group.helpers.length === 0) { + return null; + } + return renderTokenCategory( + group.key, + group.label, + group.helpers.map((helper) => { + const disabledReason = interactive ? getHelperDisabledReason(helper) : null; + const tokenId = `helper-${helper.name}`; + const isHovered = hoveredTokenId === tokenId; + const helperToken = ( + setHoveredTokenId(tokenId) : undefined} + onMouseLeave={interactive && !disabledReason ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} + onClick={interactive && !disabledReason ? () => insertExpressionJsonHelper(helper) : undefined} + > + {helper.name} + + ); + return ( + + {helperToken} + + ); + }), + compact ? compactHelperCategoryStyle : undefined, + compact ? compactHelperTokenListStyle : undefined, + ); + }; + + // Helper layout: Desktop uses 4-column grid with preferred helper groups in top positions, others stacked below. + // Mobile collapses to single column. This layout accommodates ~15 helper groups across screen sizes. + const renderHelperTokenGroups = (interactive: boolean) => { + if (isDesktopLayout) { + return ( +
+ {HELPER_DESKTOP_COLUMN_LAYOUT.map((column) => ( +
+ {renderHelperTokenCategory(column.top, interactive)} + {column.bottom ? renderHelperTokenCategory(column.bottom, interactive, true) : null} +
+ ))} +
+ ); + } + return ( +
+ {helperGroups.map((group) => renderHelperTokenCategory(group.key, interactive))} +
+ ); + }; + + const missingCustomReferencesByDerivedField = useMemo(() => { + const availableCustomFieldKeys = new Set((configuredFields.data || []).map((field) => field.key)); + const missingMap: Record = {}; + + (derivedFields.data || []).forEach((derivedField) => { + const missingReferences = getExtraFieldReferences(derivedField.expression_json || undefined).filter( + (reference) => !availableCustomFieldKeys.has(reference), + ); + if (missingReferences.length > 0) { + missingMap[derivedField.key] = missingReferences; + } + }); + + return missingMap; + }, [configuredFields.data, derivedFields.data]); + + const hasBrokenFormulaDependencies = useMemo( + () => Object.keys(missingCustomReferencesByDerivedField).length > 0, + [missingCustomReferencesByDerivedField], + ); + + const openCreateDerived = () => { + // Reset modal UI state and pending operations when opening for new field creation. + // This ensures clean slate: no stale helper selections, panel states, or preview errors. + setEditingDerivedKey(null); + setPreviewText(null); + setPreviewErrorText(null); + setSampleValuesAutoUpdateEnabled(true); + setPendingOperatorInsert(null); + autoManagedSampleReferencesRef.current.clear(); + derivedForm.resetFields(); + derivedForm.setFieldsValue({ + key: "", + name: "", + description: "", + surfaces: [FormulaFieldSurface.show], + include_in_api: false, + expression_json: "", + sample_values: "{}", + }); + setDerivedModalOpen(true); + }; + + const openEditDerived = (record: DerivedField) => { + setEditingDerivedKey(record.key); + setPreviewText(null); + setPreviewErrorText(null); + setSampleValuesAutoUpdateEnabled(true); + setPendingOperatorInsert(null); + autoManagedSampleReferencesRef.current.clear(); + derivedForm.setFieldsValue({ + key: record.key, + name: record.name, + description: record.description || "", + surfaces: record.surfaces, + include_in_api: record.include_in_api ?? false, + expression_json: record.expression_json ? JSON.stringify(record.expression_json, null, 2) : "", + sample_values: "{}", + }); + setDerivedModalOpen(true); + }; + + const closeDerivedModal = () => { + setDerivedModalOpen(false); + setEditingDerivedKey(null); + setPreviewText(null); + setPreviewErrorText(null); + setPendingJsonHelperInsert(null); + setPendingOperatorInsert(null); + autoManagedSampleReferencesRef.current.clear(); + // Keep selection state so reopening the modal preserves cursor position + // expressionJsonSelectionRef is preserved intentionally + derivedForm.resetFields(); + }; + + // Distinguish snippet/format writes from manual typing so guided IF/operator state + // survives programmatic editor updates instead of being cleared as "manual edits". + const setExpressionJsonProgrammatically = useCallback( + (nextValue: string) => { + expressionJsonProgrammaticValueRef.current = nextValue; + derivedForm.setFieldValue("expression_json", nextValue); + }, + [derivedForm], + ); + + // Insert a JSON snippet into the expression editor while honoring any active guided + // operator state (including IF compare-flow) before writing final JSON text. + const insertExpressionJsonSnippet = (snippet: string) => { + let snippetToInsert = snippet; + let replaceEditorOnComplete = false; + // While an operator is pending, treat each clicked helper/reference snippet as one operand. + // Insert only after collecting the full required operand count for that operator. + if (pendingOperatorInsert) { + // `if` guided mode requires a comparison operator before accepting condition operands. + if ( + pendingOperatorInsert.operator === "if" && + pendingOperatorInsert.selectedOperands.length === 0 && + !pendingOperatorInsert.pendingIfComparisonOperator + ) { + messageApi.warning("Select a comparison operator before choosing IF condition operands."); + return; + } + try { + const parsedOperand = JSON.parse(snippet) as unknown; + if ( + pendingOperatorInsert.operator === "if" && + pendingOperatorInsert.selectedOperands.length === 0 && + pendingOperatorInsert.pendingIfComparisonOperator + ) { + const conditionOperands = [...(pendingOperatorInsert.pendingIfComparisonOperands || []), parsedOperand]; + if (conditionOperands.length < 2) { + setPendingOperatorInsert({ + ...pendingOperatorInsert, + pendingIfComparisonOperands: conditionOperands, + }); + return; + } + + const ifConditionNode = { + [pendingOperatorInsert.pendingIfComparisonOperator]: conditionOperands.slice(0, 2), + }; + const selectedOperands = [ifConditionNode]; + if (selectedOperands.length < pendingOperatorInsert.requiredOperandCount) { + setPendingOperatorInsert({ + ...pendingOperatorInsert, + selectedOperands, + pendingIfComparisonOperator: null, + pendingIfComparisonOperands: [], + }); + return; + } + + snippetToInsert = JSON.stringify( + { + [pendingOperatorInsert.operator]: selectedOperands.slice(0, pendingOperatorInsert.requiredOperandCount), + }, + null, + 2, + ); + } else { + const selectedOperands = [...pendingOperatorInsert.selectedOperands, parsedOperand]; + if (selectedOperands.length < pendingOperatorInsert.requiredOperandCount) { + setPendingOperatorInsert({ + ...pendingOperatorInsert, + selectedOperands, + }); + return; + } + snippetToInsert = JSON.stringify( + { + [pendingOperatorInsert.operator]: selectedOperands.slice(0, pendingOperatorInsert.requiredOperandCount), + }, + null, + 2, + ); + } + replaceEditorOnComplete = Boolean(pendingOperatorInsert.replaceEditorOnComplete); + } catch { + messageApi.warning(t("settings.formula_fields.formula.expression_json_invalid")); + } + setPendingOperatorInsert(null); + } + + const currentValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + if (replaceEditorOnComplete) { + const nextCursor = snippetToInsert.length; + setExpressionJsonProgrammatically(snippetToInsert); + expressionJsonSelectionRef.current = { from: nextCursor, to: nextCursor }; + requestAnimationFrame(() => { + const editor = expressionJsonEditorRef.current; + if (!editor) { + return; + } + editor.focus(); + editor.dispatch({ + selection: { anchor: nextCursor, head: nextCursor }, + scrollIntoView: true, + }); + }); + return; + } + + const hasEditor = expressionJsonEditorRef.current !== null; + if (!hasEditor) { + const prefix = currentValue.trim() === "" ? "" : "\n"; + setExpressionJsonProgrammatically(`${currentValue}${prefix}${snippetToInsert}`); + return; + } + + const start = expressionJsonSelectionRef.current.from; + const end = expressionJsonSelectionRef.current.to; + const nextValue = `${currentValue.slice(0, start)}${snippetToInsert}${currentValue.slice(end)}`; + const nextCursor = start + snippetToInsert.length; + setExpressionJsonProgrammatically(nextValue); + expressionJsonSelectionRef.current = { from: nextCursor, to: nextCursor }; + + requestAnimationFrame(() => { + const editor = expressionJsonEditorRef.current; + if (!editor) { + return; + } + editor.focus(); + editor.dispatch({ + selection: { anchor: nextCursor, head: nextCursor }, + scrollIntoView: true, + }); + }); + }; + + const insertExpressionJsonReference = (reference: string) => { + if (!pendingHelperDefinition) { + insertExpressionJsonSnippet(JSON.stringify({ var: reference }, null, 2)); + return; + } + + if (!isReferenceCompatibleWithPendingHelper(reference)) { + messageApi.warning( + t("settings.formula_fields.formula.json_builder.reference_incompatible_reason", { + helper: pendingHelperDefinition.name, + }), + ); + return; + } + + const pendingState = pendingJsonHelperInsert; + if (!pendingState) { + return; + } + + const requiredReferenceCount = getHelperReferenceCount(pendingHelperDefinition); + const selectedOperands = [...pendingState.selectedOperands, { kind: "reference", value: reference } as const]; + if (selectedOperands.length < requiredReferenceCount) { + setPendingJsonHelperInsert({ + helperName: pendingHelperDefinition.name, + selectedOperands, + }); + return; + } + + const snippet = { + [pendingHelperDefinition.name]: selectedOperands.slice(0, requiredReferenceCount).map((operand) => ( + operand.kind === "reference" ? { var: operand.value } : { [operand.value]: [] } + )), + }; + // Insert ready-to-parse JSON Logic objects so users can build expressions without memorizing + // raw AST syntax. Pending helper operands may be refs or helper calls like today(). + insertExpressionJsonSnippet(JSON.stringify(snippet, null, 2)); + setPendingJsonHelperInsert(null); + }; + + const insertExpressionJsonHelper = (helper: FormulaHelperDefinition) => { + // Treat today() as a valid date-diff operand while a pending helper is collecting + // operands, so clicks produce one combined snippet instead of standalone {"today":[]}. + if (pendingHelperDefinition && helper.insert_mode === "none" && helper.name === "today" && pendingHelperDefinition.category === "date_diff") { + const pendingState = pendingJsonHelperInsert; + if (!pendingState) { + return; + } + const requiredReferenceCount = getHelperReferenceCount(pendingHelperDefinition); + const selectedOperands = [...pendingState.selectedOperands, { kind: "helper", value: helper.name } as const]; + if (selectedOperands.length < requiredReferenceCount) { + setPendingJsonHelperInsert({ + helperName: pendingHelperDefinition.name, + selectedOperands, + }); + return; + } + const snippet = { + [pendingHelperDefinition.name]: selectedOperands.slice(0, requiredReferenceCount).map((operand) => ( + operand.kind === "reference" ? { var: operand.value } : { [operand.value]: [] } + )), + }; + // Allow date-diff helpers to consume dynamic today() as an operand instead of inserting it standalone. + insertExpressionJsonSnippet(JSON.stringify(snippet, null, 2)); + setPendingJsonHelperInsert(null); + return; + } + + if (helper.insert_mode === "none") { + insertExpressionJsonSnippet(JSON.stringify({ [helper.name]: [] }, null, 2)); + setPendingJsonHelperInsert(null); + return; + } + const disabledReason = getHelperDisabledReason(helper); + if (disabledReason) { + messageApi.warning(disabledReason); + return; + } + // Keep helper insertion staged until required reference tokens are selected, so helpers with + // multiple reference operands (for example days_between/hours_between) can be assembled safely. + setPendingJsonHelperInsert({ helperName: helper.name, selectedOperands: [] }); + messageApi.info( + t("settings.formula_fields.formula.json_builder.pending_helper", { + helper: helper.name, + selected: 0, + total: getHelperReferenceCount(helper), + }), + ); + }; + + const insertPendingHelperWithoutReferences = () => { + if (!pendingHelperDefinition) { + return; + } + const placeholderSnippet = { + [pendingHelperDefinition.name]: buildHelperPlaceholderArguments(pendingHelperDefinition), + }; + insertExpressionJsonSnippet(JSON.stringify(placeholderSnippet, null, 2)); + setPendingJsonHelperInsert(null); + }; + const cancelPendingHelperInsert = () => { + setPendingJsonHelperInsert(null); + setPendingOperatorInsert(null); + }; + + // Handle operator-token clicks in two modes: + // 1) direct snippet insertion and + // 2) guided operand collection when starting from an empty expression. + const insertExpressionJsonOperator = (operator: string) => { + // When `if` scaffolding is active and waiting for a comparison choice, only accept + // comparison operators for the condition node builder. + if (isAwaitingIfComparisonOperator) { + if (!IF_CONDITION_COMPARISON_OPERATORS.has(operator)) { + messageApi.info(t("settings.formula_fields.formula.json_builder.if_step_condition_operator")); + return; + } + if (!pendingOperatorInsert) { + return; + } + setPendingOperatorInsert({ + ...pendingOperatorInsert, + pendingIfComparisonOperator: operator, + pendingIfComparisonOperands: [], + }); + messageApi.info( + t("settings.formula_fields.formula.json_builder.pending_helper", { + helper: "if", + selected: 1, + total: 5, + }), + ); + return; + } + + const currentValue = ((derivedForm.getFieldValue("expression_json") as string | undefined) || "").trim(); + // Option 3 behavior for IF: + // 1) insert a readable scaffold immediately + // 2) keep guided click-flow active so further clicks can complete condition/then/else. + if (currentValue === "" && operator === "if") { + insertExpressionJsonSnippet(IF_SCAFFOLD_SNIPPET); + setPendingOperatorInsert({ + operator, + selectedOperands: [], + requiredOperandCount: getOperatorOperandCount(operator), + pendingIfComparisonOperator: null, + pendingIfComparisonOperands: [], + replaceEditorOnComplete: true, + }); + setPendingJsonHelperInsert(null); + messageApi.info( + t("settings.formula_fields.formula.json_builder.pending_helper", { + helper: operator, + selected: 0, + total: 5, + }), + ); + return; + } + + // Start guided operator flow on empty expressions so users can click operator -> operands + // and get a complete JSON snippet without placeholder vars like left/right. + if (currentValue === "") { + setPendingOperatorInsert({ + operator, + selectedOperands: [], + requiredOperandCount: getOperatorOperandCount(operator), + }); + setPendingJsonHelperInsert(null); + messageApi.info( + t("settings.formula_fields.formula.json_builder.pending_helper", { + helper: operator, + selected: 0, + total: getOperatorOperandCount(operator), + }), + ); + return; + } + + const snippet = JSON_LOGIC_OPERATOR_SNIPPETS[operator]; + if (!snippet) { + return; + } + insertExpressionJsonSnippet(snippet); + setPendingJsonHelperInsert(null); + }; + + const formatExpressionJson = async () => { + try { + const currentValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + const parsed = parseExpressionJson(currentValue); + if (!parsed) { + return; + } + setExpressionJsonProgrammatically(JSON.stringify(parsed, null, 2)); + messageApi.success(t("settings.formula_fields.formula.json_builder.formatted")); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + const saveDerived = async () => { + try { + const values = await derivedForm.validateFields(); + const key = editingDerivedKey || values.key; + const expressionJson = parseExpressionJson(values.expression_json); + if (!expressionJson) { + throw new Error(t("settings.formula_fields.formula.expression_json_required")); + } + // Keep backend contract intact without exposing Result Type controls in the editor: + // infer from JSON when possible, otherwise preserve existing type (edit) or default new fields. + const inferredType = toDerivedFieldType(inferExpressionJsonType(expressionJson)); + const existingType = editingDerivedKey + ? derivedFields.data?.find((field) => field.key === editingDerivedKey)?.result_type + : undefined; + const persistedResultType = inferredType ?? existingType ?? DerivedFieldType.number; + + await setDerivedField.mutateAsync({ + key, + params: { + name: values.name, + description: values.description || undefined, + result_type: persistedResultType, + expression_json: expressionJson, + surfaces: values.surfaces, + // List-surface formula fields are always hideable through Hide Columns. Persist this + // explicitly so pre-existing records with false are normalized on save. + allow_list_column_toggle: (values.surfaces as string[]).includes(FormulaFieldSurface.list), + include_in_api: values.include_in_api ?? false, + }, + }); + + messageApi.success( + t(editingDerivedKey ? "settings.formula_fields.formula.messages.updated" : "settings.formula_fields.formula.messages.created", { + name: values.name, + }), + ); + closeDerivedModal(); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + // Reconcile current sample JSON against detected expression references by: + // 1) adding missing reference paths with type-aware defaults and + // 2) pruning stale keys that were auto-managed but are no longer referenced. + const buildSampleValuesWithMissingReferences = (currentSampleValues: Record) => { + const mergedSampleValues = JSON.parse(JSON.stringify(currentSampleValues)) as Record; + const insertedReferences: string[] = []; + const removedReferences: string[] = []; + const detectedReferenceSet = new Set(detectedExpressionReferences); + + const trackedAutoReferences = autoManagedSampleReferencesRef.current; + [...trackedAutoReferences].forEach((reference) => { + if (detectedReferenceSet.has(reference)) { + return; + } + if (removeReferencePathIfPresent(mergedSampleValues, reference)) { + removedReferences.push(reference); + } + trackedAutoReferences.delete(reference); + }); + + detectedExpressionReferences.forEach((reference) => { + const referenceKind = referenceKindByName[reference] || "unknown"; + // Seed new sample keys with type-aware defaults so previews work immediately + // and users can adjust values instead of building sample JSON from scratch. + const defaultValue = getSampleDefaultValue( + referenceKind, + reference, + configuredFieldByReference[reference], + ); + if (insertReferencePathIfMissing(mergedSampleValues, reference, defaultValue)) { + insertedReferences.push(reference); + trackedAutoReferences.add(reference); + } + }); + + return { + mergedSampleValues, + insertedReferences, + removedReferences, + }; + }; + + // Execute preview with request sequencing so stale async responses never overwrite + // newer editor state while users are typing quickly. + const runPreview = useCallback(async (showMessageOnError: boolean) => { + const requestId = previewRequestRef.current + 1; + previewRequestRef.current = requestId; + try { + const sampleValues = parseSampleValues((derivedForm.getFieldValue("sample_values") as string | undefined) || "{}"); + const expressionJson = parseExpressionJson(derivedForm.getFieldValue("expression_json") as string | undefined); + if (!expressionJson) { + throw new Error(t("settings.formula_fields.formula.expression_json_required")); + } + // Preview uses sample JSON only as a sandbox for validating formulas before they are exposed + // on show/list/template surfaces. + const preview = await previewDerivedField.mutateAsync({ + expression_json: expressionJson, + sample_values: sampleValues, + }); + + if (requestId !== previewRequestRef.current) { + return; + } + setPreviewText(formatPreviewValue(preview.result)); + setPreviewErrorText(null); + } catch (errInfo) { + if (requestId !== previewRequestRef.current) { + return; + } + setPreviewText(null); + if (errInfo instanceof Error) { + setPreviewErrorText(errInfo.message); + } else { + setPreviewErrorText(t("settings.formula_fields.formula.preview.error_fallback")); + } + if (showMessageOnError && errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }, [derivedForm, messageApi, previewDerivedField, t]); + + // Apply one synchronization pass between expression refs and sample JSON. + // The pass is non-destructive for user-owned keys while still cleaning stale auto-managed refs. + const syncMissingSampleValueKeys = (showMessageOnError: boolean) => { + let currentSampleValues: Record; + try { + currentSampleValues = parseSampleValues((derivedForm.getFieldValue("sample_values") as string | undefined) || "{}"); + } catch (errInfo) { + if (showMessageOnError && errInfo instanceof Error) { + messageApi.warning(errInfo.message); + } + return false; + } + + // Apply additive scaffolding and dead-key pruning without touching user-owned sample keys. + const { mergedSampleValues, insertedReferences, removedReferences } = buildSampleValuesWithMissingReferences(currentSampleValues); + + if (insertedReferences.length === 0 && removedReferences.length === 0) { + return true; + } + + derivedForm.setFieldValue("sample_values", JSON.stringify(mergedSampleValues, null, 2)); + return true; + }; + + useEffect(() => { + if (!derivedModalOpen) { + return; + } + + if (!sampleValuesAutoUpdateEnabled) { + return; + } + + if (((expressionJsonValue || "").trim()) === "") { + autoManagedSampleReferencesRef.current.clear(); + const currentSampleValuesRaw = (derivedForm.getFieldValue("sample_values") as string | undefined) || ""; + if (currentSampleValuesRaw.trim() !== "{}") { + derivedForm.setFieldValue("sample_values", "{}"); + } + return; + } + + // Ignore invalid JSON editing states so transient typing does not create stale sample keys. + if (parsedExpressionJson === null) { + return; + } + + // Keep sample values synchronized with newly referenced variables while preserving + // any user-authored values that already exist in the sample JSON. + syncMissingSampleValueKeys(false); + }, [ + derivedModalOpen, + expressionJsonValue, + detectedExpressionReferences, + sampleValuesValue, + referenceKindByName, + configuredFieldByReference, + sampleValuesAutoUpdateEnabled, + derivedForm, + ]); + + useEffect(() => { + if (!derivedModalOpen) { + return; + } + + if (((expressionJsonValue || "").trim()) === "") { + setPreviewText(null); + setPreviewErrorText(t("settings.formula_fields.formula.expression_json_required")); + return; + } + + // Debounce preview with 700ms to allow formula typing without constant re-evaluation. + // Preview API calls can be slow, especially with complex expressions. Short debounce (350ms) + // would cause excessive requests during active editing. Longer debounce gives better UX. + const timeout = window.setTimeout(() => { + void runPreview(false); + }, 700); + + return () => window.clearTimeout(timeout); + }, [derivedModalOpen, expressionJsonValue, sampleValuesValue, runPreview, t]); + + const removeDerived = async (record: DerivedField) => { + try { + await deleteDerivedField.mutateAsync(record.key); + messageApi.success(t("settings.formula_fields.formula.messages.deleted", { name: record.name })); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + }; + + const derivedColumns: ColumnType[] = [ + { + title: t("settings.formula_fields.formula.columns.key"), + dataIndex: "key", + key: "key", + width: "10%", + }, + { + title: t("settings.formula_fields.formula.columns.path"), + key: "path", + width: "14%", + render: (_: unknown, record) => {`derived.${record.key}`}, + }, + { + title: t("settings.formula_fields.formula.columns.name"), + dataIndex: "name", + key: "name", + width: "14%", + }, + { + title: t("settings.formula_fields.formula.columns.expression"), + dataIndex: "expression_json", + key: "expression", + width: "34%", + render: (_value: Record | undefined, record) => { + const expressionValue = record.expression_json ? JSON.stringify(record.expression_json) : ""; + const missingReferences = missingCustomReferencesByDerivedField[record.key] || []; + return ( + + + {expressionValue} + + {missingReferences.length > 0 && ( + + {t("settings.formula_fields.formula.missing_references", { + references: missingReferences.join(", "), + })} + + )} + + ); + }, + }, + { + title: t("settings.formula_fields.formula.columns.surfaces"), + dataIndex: "surfaces", + key: "surfaces", + width: "20%", + // Keep one at-a-glance destination column by showing API as a tag alongside display surfaces. + render: (surfaces: string[], record) => ( + + {surfaces.map((surface) => ( + {t(`settings.formula_fields.surfaces.${surface}`)} + ))} + {record.include_in_api ? API : null} + + ), + }, + { + title: "", + key: "operation", + width: "12%", + render: (_: unknown, record) => ( + + + removeDerived(record)} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + + + + ), + }, + ]; + + // Render a compact side preview card beside Sample Values so result/error state stays visible + // without consuming extra vertical space under the editor. + const previewPanelContent = useMemo(() => { + if (previewErrorText) { + return ( + + {previewErrorText} + + ); + } + + if (previewText == null) { + return ( + + {t("settings.formula_fields.formula.preview.empty")} + + ); + } + + return ( + + + {`${derivedKeyPath} =`} + + + {previewText} + + + ); + }, [derivedKeyPath, previewErrorText, previewText, t, token.fontSizeLG]); + const pendingHelperHint = useMemo(() => { + if (pendingHelperDefinition && pendingJsonHelperInsert) { + const selected = pendingJsonHelperInsert.selectedOperands.length; + const total = getHelperReferenceCount(pendingHelperDefinition); + return { + helper: pendingHelperDefinition.name, + selected, + total, + allowHelperOnly: true, + }; + } + if (pendingOperatorInsert) { + if (pendingOperatorInsert.operator === "if") { + const completedIfSteps = (() => { + if (pendingOperatorInsert.selectedOperands.length === 0) { + if (!pendingOperatorInsert.pendingIfComparisonOperator) { + return 0; + } + return 1 + (pendingOperatorInsert.pendingIfComparisonOperands?.length || 0); + } + // After condition is built, steps are: + // 3) compare-left-right complete + 4) then + 5) else + return 3 + (pendingOperatorInsert.selectedOperands.length - 1); + })(); + return { + helper: pendingOperatorInsert.operator, + selected: Math.min(5, completedIfSteps), + total: 5, + allowHelperOnly: false, + stepLabelKey: getIfPendingStepLabelKey(pendingOperatorInsert), + }; + } + return { + helper: pendingOperatorInsert.operator, + selected: pendingOperatorInsert.selectedOperands.length, + total: pendingOperatorInsert.requiredOperandCount, + allowHelperOnly: false, + }; + } + return null; + }, [getHelperReferenceCount, pendingHelperDefinition, pendingJsonHelperInsert, pendingOperatorInsert]); + return ( + <> + + + + {t("settings.formula_fields.formula.header")}: {niceName} + + + + + + {t("settings.formula_fields.help_links.formula")} + + + + + {t("settings.formula_fields.formula.intro")} + + + {t("settings.formula_fields.formula.evaluation_model_help")} + + {hasBrokenFormulaDependencies && ( + + {t("settings.formula_fields.formula.missing_references_intro")} + + )} + + {t("settings.formula_fields.available_functions.value")} + +
+ ), + }} + onRow={(record) => { + const hasMissingReferences = (missingCustomReferencesByDerivedField[record.key] || []).length > 0; + if (!hasMissingReferences) { + return {}; + } + return { + style: { + backgroundColor: token.colorErrorBg, + }, + }; + }} + rowKey="key" + /> + + + + + {t("settings.formula_fields.formula.key_usage_help")}:{" "} + {derivedKeyPath} + + {keyLooksLikeReservedToken && ( + + + {t("settings.formula_fields.formula.key_reserved_hint", { key: derivedKeyValue })} + + )} + + )} + rules={[ + { required: true, min: 1, max: 64, pattern: /^[a-z0-9_]+$/ }, + { + validator: async (_, value) => { + if (RESERVED_DERIVED_KEY_NAMES.has(value)) { + throw new Error(t("settings.formula_fields.formula.key_reserved_hint", { key: value })); + } + }, + }, + { + validator: async (_, value) => { + if (!editingDerivedKey && derivedFields.data?.some((field) => field.key === value)) { + throw new Error(t("settings.extra_fields.non_unique_key_error")); + } + }, + }, + ]} + > + + + + + + + + + + + + + + {/* Keep all visibility controls visible in one row so users can decide targets without opening menus. */} + + + + + + + {t("settings.formula_fields.formula.display_targets.api")} + + + + + + + + + + {labeledField( + "settings.formula_fields.formula.columns.expression_json", + "settings.formula_fields.formula.expression_json_help", + )} + + {t("settings.formula_fields.help_links.formula_json")} + + + + } + name="expression_json" + trigger="onChange" + getValueFromEvent={(value: string) => value} + rules={[ + { + validator: async (_, value) => { + const parsed = parseExpressionJson( + value, + t("settings.formula_fields.formula.expression_json_invalid"), + ); + if (!parsed) { + throw new Error(t("settings.formula_fields.formula.expression_json_required")); + } + // Validate that all referenced custom fields still exist (prevent silent formula failures after field deletion) + const referencedCustomFields = getExtraFieldReferences(parsed); + const availableCustomFields = new Set((configuredFields.data || []).map((field) => field.key)); + const missingFields = referencedCustomFields.filter((fieldKey) => !availableCustomFields.has(fieldKey)); + if (missingFields.length > 0) { + throw new Error( + t("settings.formula_fields.formula.missing_references", { references: missingFields.join(", ") }), + ); + } + }, + }, + ]} + > +
+ {/* Keep expression editor and operator rail in one row so hiding operators can + immediately reclaim horizontal space without changing editor height. */} + +
+
+ { + expressionJsonEditorRef.current = editor; + const mainSelection = editor.state.selection.main; + expressionJsonSelectionRef.current = { from: mainSelection.from, to: mainSelection.to }; + }} + onUpdate={(viewUpdate) => { + const mainSelection = viewUpdate.state.selection.main; + expressionJsonSelectionRef.current = { from: mainSelection.from, to: mainSelection.to }; + }} + onChange={(value) => { + // Ignore one editor change event when it mirrors a programmatic setFieldValue + // so guided helper/operator state is only reset on actual user typing. + if (expressionJsonProgrammaticValueRef.current !== null) { + if (value === expressionJsonProgrammaticValueRef.current) { + expressionJsonProgrammaticValueRef.current = null; + return; + } + expressionJsonProgrammaticValueRef.current = null; + } + // Ignore no-op sync events where CodeMirror re-emits the same text that is + // already in the form model. This prevents guided IF/operator state from + // being canceled before the user clicks the next required token. + const currentExpressionValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + if (value === currentExpressionValue) { + return; + } + // Manual edits should immediately exit guided pending insert modes. + if (pendingJsonHelperInsert) { + setPendingJsonHelperInsert(null); + } + if (pendingOperatorInsert) { + setPendingOperatorInsert(null); + } + derivedForm.setFieldValue("expression_json", value); + }} + /> +
+ {/* Keep editor action controls anchored under the expression editor. */} + + + + + {isDesktopOperatorPanel ? ( + +
+ {/* Render operator rail only when enabled so expression editor can expand right when hidden. */} + {showInlineOperatorPanel && ( +
+ {/* Operator panel stays beside the editor so token insertion does not push helper/reference sections down. */} +
+ + {t("settings.formula_fields.formula.token_sections.operators")} + +
{renderOperatorTokenGroups(true)}
+
+
+ )} +
+
+
+ {/* Show helper/operators before references so helper-first insertion flow is visually guided. */} + + + + + {t("settings.formula_fields.formula.json_builder.operators_title")} + + + + + + {t("settings.formula_fields.help_links.formula_tokens")} + + + + + ) : null} + + ) : null} + +
{renderHelperTokenGroups(true)}
+ +
+ + + {t("settings.formula_fields.formula.reference_picker.label")} + + + + + +
+
+ {compactReferenceOptions.map((reference) => { + const referenceCompatible = isReferenceCompatibleWithPendingHelper(reference.value); + const isSelectedForPendingHelper = Boolean( + pendingJsonHelperInsert?.selectedOperands.some( + (operand) => operand.kind === "reference" && operand.value === reference.value, + ), + ); + const disabledReason = + !referenceCompatible && pendingHelperDefinition + ? t("settings.formula_fields.formula.json_builder.reference_incompatible_reason", { + helper: pendingHelperDefinition.name, + }) + : null; + const referenceToken = ( + setHoveredTokenId(`reference-${reference.value}`) : undefined} + onMouseLeave={!disabledReason ? () => setHoveredTokenId((current) => (current === `reference-${reference.value}` ? null : current)) : undefined} + onClick={!disabledReason ? () => insertExpressionJsonReference(reference.value) : undefined} + > + {reference.label} + + ); + // Keep a stable wrapper shape for all reference tokens so disabled/tooltip states + // do not cause reflow when helper compatibility changes. + const content = ( + + {referenceToken} + + ); + return ( +
+ {content} +
+ ); + })} +
+
+
+
+ + + ) : null} + + { + parseSampleValues( + value, + t("settings.formula_fields.formula.sample_values_invalid"), + ); + }, + }, + ]} + > + + {/* Keep labels in one row and content in the next so Preview is clearly outside the card. */} + + + + {labeledField("settings.formula_fields.formula.sample_values", "settings.formula_fields.formula.tooltips.sample_values")} + + Auto-update + setSampleValuesAutoUpdateEnabled(checked)} + /> + + + + + + {t("settings.formula_fields.formula.preview.panel_title")} + + + + + +
+ { + derivedForm.setFieldValue("sample_values", value); + }} + /> +
+ + +
+
+ {previewPanelContent} +
+
+ + + {/* Surface detected references and clearly mark only invalid/undefined entries. + Missing paths are auto-scaffolded while typing and preview updates automatically. */} + + + + {t("settings.formula_fields.formula.sample_values_detected_references")} + + {detectedExpressionReferences.length > 0 ? ( + detectedExpressionReferences.map((reference) => { + const isDefined = hasValidSampleValues && !missingSampleValueReferences.includes(reference); + const statusTooltip = isDefined + ? undefined + : t("settings.formula_fields.formula.sample_values_reference_invalid"); + const referenceText = ( + + {reference} + + ); + return ( + + {referenceText} + + ); + }) + ) : ( + + {t("settings.formula_fields.formula.sample_values_detected_references_empty")} + + )} + + + + + + + {contextHolder} + + ); +} diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index e710f1368..92165a1a0 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -36,7 +36,7 @@ import { useSpoolmanMaterials, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; -import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { FormulaFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { useCurrencyFormatter } from "../../utils/settings"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; @@ -181,16 +181,14 @@ export const SpoolList = () => { ); const liveDataSource = useLiveify("spool", queryDataSource, collapseSpool); const listFormulaFields = useMemo( - () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.list), + () => getFormulaFieldsForSurface(formulaFields.data, FormulaFieldSurface.list), [formulaFields.data], ); - const toggleableListFormulaFields = useMemo( - () => listFormulaFields.filter((field) => field.allow_list_column_toggle), - [listFormulaFields], - ); + // All list-surface formula fields are eligible for hide/show in the column picker, + // so we map every list formula to its derived column key here. const toggleableDerivedColumnKeys = useMemo( - () => toggleableListFormulaFields.map((field) => `derived.${field.key}`), - [toggleableListFormulaFields], + () => listFormulaFields.map((field) => `derived.${field.key}`), + [listFormulaFields], ); const allColumnsWithExtraFields = useMemo( () => [ @@ -295,6 +293,8 @@ export const SpoolList = () => { }; const updateColumnSelections = (selectedKeys: string[]) => { + // Persist core column visibility separately from derived-column visibility so + // derived keys can be toggled without rewriting the base showColumns state. setShowColumns(selectedKeys.filter((key) => !toggleableDerivedColumnKeys.includes(key))); setHiddenDerivedColumns(toggleableDerivedColumnKeys.filter((key) => !selectedKeys.includes(key))); }; @@ -344,7 +344,7 @@ export const SpoolList = () => { }; } if (column_id.indexOf("derived.") === 0) { - const formulaField = toggleableListFormulaFields.find((field) => `derived.${field.key}` === column_id); + const formulaField = listFormulaFields.find((field) => `derived.${field.key}` === column_id); return { key: column_id, label: formulaField?.name ?? column_id, @@ -510,7 +510,7 @@ export const SpoolList = () => { ...listFormulaFields.map( (field) => { const derivedColumnKey = `derived.${field.key}`; - if (field.allow_list_column_toggle && hiddenDerivedColumns.includes(derivedColumnKey)) { + if (hiddenDerivedColumns.includes(derivedColumnKey)) { return undefined; } diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index e86a70c93..c0cead4f3 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -10,7 +10,7 @@ import { NumberFieldUnit } from "../../components/numberField"; import SpoolIcon from "../../components/spoolIcon"; import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { enrichText } from "../../utils/parsing"; -import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { FormulaFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; import { getBasePath } from "../../utils/url"; import { IFilament } from "../filaments/model"; @@ -36,7 +36,7 @@ export const SpoolShow = () => { const record = data?.data; const showFormulaFields = useMemo( - () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.show), + () => getFormulaFieldsForSurface(formulaFields.data, FormulaFieldSurface.show), [formulaFields.data], ); const derivedValues = useMemo( @@ -234,7 +234,7 @@ export const SpoolShow = () => { {extraFields?.data?.map((field, index) => ( ))} - {showFormulaFields.length > 0 && {t("settings.complex_fields.formula.header")}} + {showFormulaFields.length > 0 && {t("settings.formula_fields.formula.header")}} {showFormulaFields.map((field) => ( {field.name} diff --git a/client/src/pages/vendors/list.tsx b/client/src/pages/vendors/list.tsx index 7ceb000c0..0c40fe5ac 100644 --- a/client/src/pages/vendors/list.tsx +++ b/client/src/pages/vendors/list.tsx @@ -18,7 +18,7 @@ import { import { useLiveify } from "../../components/liveify"; import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { removeUndefined } from "../../utils/filtering"; -import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { FormulaFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { IVendor } from "./model"; @@ -91,16 +91,14 @@ export const VendorList = () => { useCallback((record: IVendor) => record, []), ); const listFormulaFields = useMemo( - () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.list), + () => getFormulaFieldsForSurface(formulaFields.data, FormulaFieldSurface.list), [formulaFields.data], ); - const toggleableListFormulaFields = useMemo( - () => listFormulaFields.filter((field) => field.allow_list_column_toggle), - [listFormulaFields], - ); + // All list-surface formula fields are eligible for hide/show in the column picker, + // so we map every list formula to its derived column key here. const toggleableDerivedColumnKeys = useMemo( - () => toggleableListFormulaFields.map((field) => `derived.${field.key}`), - [toggleableListFormulaFields], + () => listFormulaFields.map((field) => `derived.${field.key}`), + [listFormulaFields], ); const allColumnsWithExtraFields = useMemo( () => [ @@ -146,6 +144,8 @@ export const VendorList = () => { }; const updateColumnSelections = (selectedKeys: string[]) => { + // Persist core column visibility separately from derived-column visibility so + // derived keys can be toggled without rewriting the base showColumns state. setShowColumns(selectedKeys.filter((key) => !toggleableDerivedColumnKeys.includes(key))); setHiddenDerivedColumns(toggleableDerivedColumnKeys.filter((key) => !selectedKeys.includes(key))); }; @@ -177,7 +177,7 @@ export const VendorList = () => { }; } if (column_id.indexOf("derived.") === 0) { - const formulaField = toggleableListFormulaFields.find((field) => `derived.${field.key}` === column_id); + const formulaField = listFormulaFields.find((field) => `derived.${field.key}` === column_id); return { key: column_id, label: formulaField?.name ?? column_id, @@ -250,7 +250,7 @@ export const VendorList = () => { ...listFormulaFields.map( (field) => { const derivedColumnKey = `derived.${field.key}`; - if (field.allow_list_column_toggle && hiddenDerivedColumns.includes(derivedColumnKey)) { + if (hiddenDerivedColumns.includes(derivedColumnKey)) { return undefined; } diff --git a/client/src/pages/vendors/show.tsx b/client/src/pages/vendors/show.tsx index 9404c2c55..803ec91bc 100644 --- a/client/src/pages/vendors/show.tsx +++ b/client/src/pages/vendors/show.tsx @@ -7,7 +7,7 @@ import utc from "dayjs/plugin/utc"; import { ExtraFieldDisplay } from "../../components/extraFields"; import { buildFormulaValues, formatFormulaValue, getFormulaFieldsForSurface } from "../../utils/formulaFields"; import { enrichText } from "../../utils/parsing"; -import { ComplexFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; +import { FormulaFieldSurface, EntityType, useGetDerivedFields, useGetFields } from "../../utils/queryFields"; import { IVendor } from "./model"; dayjs.extend(utc); @@ -26,7 +26,7 @@ export const VendorShow = () => { const record = data?.data; const showFormulaFields = useMemo( - () => getFormulaFieldsForSurface(formulaFields.data, ComplexFieldSurface.show), + () => getFormulaFieldsForSurface(formulaFields.data, FormulaFieldSurface.show), [formulaFields.data], ); const derivedValues = useMemo( @@ -60,7 +60,7 @@ export const VendorShow = () => { {extraFields?.data?.map((field, index) => ( ))} - {showFormulaFields.length > 0 && {t("settings.complex_fields.formula.header")}} + {showFormulaFields.length > 0 && {t("settings.formula_fields.formula.header")}} {showFormulaFields.map((field) => ( {field.name} diff --git a/client/src/utils/formulaFields.ts b/client/src/utils/formulaFields.ts index 95c04cfb9..f94c20d26 100644 --- a/client/src/utils/formulaFields.ts +++ b/client/src/utils/formulaFields.ts @@ -1,4 +1,4 @@ -import { ComplexFieldSurface, DerivedField } from "./queryFields"; +import { FormulaFieldSurface, DerivedField } from "./queryFields"; type FormulaScope = object; @@ -174,6 +174,8 @@ function asNumber(value: unknown, operator: string): number { } function parseExtraValue(value: unknown): unknown { + // Extra fields are serialized in API payloads; parse opportunistically so formula + // evaluation can treat numbers/booleans/dates as values instead of raw strings. if (typeof value !== "string") { return value; } @@ -184,6 +186,44 @@ function parseExtraValue(value: unknown): unknown { } } +function normalizeFormulaScopeValue(value: unknown): unknown { + // Normalize recursively so formula evaluation sees a stable shape for nested data, + // including parsed `extra.*` payloads and dual datetime aliases. + if (Array.isArray(value)) { + return value.map((item) => normalizeFormulaScopeValue(item)); + } + + if (isRecord(value)) { + const normalized: Record = {}; + Object.entries(value).forEach(([key, nested]) => { + if (key === "extra" && isRecord(nested)) { + normalized[key] = Object.fromEntries( + Object.entries(nested).map(([extraKey, extraValue]) => [extraKey, parseExtraValue(extraValue)]), + ); + return; + } + normalized[key] = normalizeFormulaScopeValue(nested); + }); + + // Preserve both keys for compatibility with existing formulas authored with either naming. + if ("registered" in normalized && !("created_at" in normalized)) { + normalized.created_at = normalized.registered; + } + if ("created_at" in normalized && !("registered" in normalized)) { + normalized.registered = normalized.created_at; + } + return normalized; + } + + return value; +} + +function normalizeFormulaScope(scope: FormulaScope): Record { + // Ensure the evaluator always receives an object root even if caller input is malformed. + const normalized = normalizeFormulaScopeValue(scope); + return isRecord(normalized) ? normalized : {}; +} + function getReferenceValue(reference: string, scope: FormulaScope): unknown { const parts = reference.split("."); let current: unknown = scope; @@ -210,12 +250,20 @@ function getReferenceValue(reference: string, scope: FormulaScope): unknown { return current; } -function collectFormulaReferencesFromJsonLogic(node: unknown, references: Set): void { +// Defensive recursion with depth limit to prevent stack overflow on malformed expressions. +// Most real JSON Logic expressions are 3-4 levels deep; 20 provides ample margin for complex nesting. +const MAX_JSON_LOGIC_RECURSION_DEPTH = 20; + +function collectFormulaReferencesFromJsonLogic(node: unknown, references: Set, depth = 0): void { + if (depth > MAX_JSON_LOGIC_RECURSION_DEPTH) { + // Silently stop traversal at max depth to prevent stack overflow. + return; + } if (node === null || typeof node === "string" || typeof node === "number" || typeof node === "boolean") { return; } if (Array.isArray(node)) { - node.forEach((value) => collectFormulaReferencesFromJsonLogic(value, references)); + node.forEach((value) => collectFormulaReferencesFromJsonLogic(value, references, depth + 1)); return; } if (!isRecord(node)) { @@ -235,12 +283,12 @@ function collectFormulaReferencesFromJsonLogic(node: unknown, references: Set 1) { - collectFormulaReferencesFromJsonLogic(args[1], references); + collectFormulaReferencesFromJsonLogic(args[1], references, depth + 1); } return; } - args.forEach((arg) => collectFormulaReferencesFromJsonLogic(arg, references)); + args.forEach((arg) => collectFormulaReferencesFromJsonLogic(arg, references, depth + 1)); } export function getFormulaReferencesFromJsonLogic(expressionJson: Record): string[] { @@ -500,22 +548,25 @@ export function evaluateFormulaJsonLogic(expressionJson: Record } export function getTemplateFormulaFields(fields: DerivedField[] | undefined): DerivedField[] { - return (fields || []).filter((field) => field.surfaces.includes(ComplexFieldSurface.template)); + return (fields || []).filter((field) => field.surfaces.includes(FormulaFieldSurface.template)); } export function getFormulaFieldsForSurface( fields: DerivedField[] | undefined, - surface: ComplexFieldSurface, + surface: FormulaFieldSurface, ): DerivedField[] { return (fields || []).filter((field) => field.surfaces.includes(surface)); } export function buildFormulaValues(scope: FormulaScope, fields: DerivedField[]): Record { const values: Record = {}; + // Evaluate against the normalized scope once per row so each field sees identical + // compatibility aliases (registered/created_at) and parsed extra values. + const normalizedScope = normalizeFormulaScope(scope); fields.forEach((field) => { try { if (field.expression_json) { - values[field.key] = evaluateFormulaJsonLogic(field.expression_json, scope); + values[field.key] = evaluateFormulaJsonLogic(field.expression_json, normalizedScope); } } catch { // Failed evaluations stay hidden so one invalid formula does not break show/list/template diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index 9b3fe6f89..41069248c 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -19,7 +19,8 @@ export enum EntityType { spool = "spool", } -export enum ComplexFieldSurface { +// Shared surface identifiers for formula fields across settings, list/show pages, and templates. +export enum FormulaFieldSurface { show = "show", edit = "edit", list = "list", diff --git a/scripts/pr-preflight.sh b/scripts/pr-preflight.sh deleted file mode 100755 index 703c7ad8d..000000000 --- a/scripts/pr-preflight.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - scripts/pr-preflight.sh [options] - -Examples: - scripts/pr-preflight.sh 874 --expected-worktree /private/tmp/spoolman_pr874_runtime_iQoS --expected-branch feat/complex-fields-framework --strict - scripts/pr-preflight.sh 874 --expected-worktree /private/tmp/spoolman_pr874_runtime_iQoS --expected-branch feat/complex-fields-framework --strict --require-container --require-url - scripts/pr-preflight.sh 876 --expected-branch tmp/pr876-template-filters --strict - -Options: - --expected-worktree Exact worktree path required for strict checks. - --expected-branch Exact branch name required for strict checks. - --require-container Require spoolman_pr_8 to be running. - --require-url Require localhost:8 to respond with HTTP status. - --strict Exit non-zero when any mismatch is found. -EOF -} - -if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -if ! [[ "$1" =~ ^[0-9]+$ ]]; then - echo "ERROR: first argument must be a numeric PR id." >&2 - usage - exit 1 -fi - -PR="$1" -shift - -EXPECTED_WORKTREE="" -EXPECTED_BRANCH="" -REQUIRE_CONTAINER=0 -REQUIRE_URL=0 -STRICT=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --expected-worktree) - EXPECTED_WORKTREE="${2:-}" - shift 2 - ;; - --expected-branch) - EXPECTED_BRANCH="${2:-}" - shift 2 - ;; - --require-container) - REQUIRE_CONTAINER=1 - shift - ;; - --require-url) - REQUIRE_URL=1 - shift - ;; - --strict) - STRICT=1 - shift - ;; - *) - echo "ERROR: unknown option: $1" >&2 - usage - exit 1 - ;; - esac -done - -if ! git rev-parse --show-toplevel >/dev/null 2>&1; then - echo "ERROR: not inside a git repository." >&2 - exit 1 -fi - -CURRENT_PWD="$(pwd -P)" -REPO_ROOT="$(git rev-parse --show-toplevel)" -CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" -HEAD_LINE="$(git log --oneline -n 1)" - -PORT="8${PR}" -CONTAINER="spoolman_pr${PR}_${PORT}" -DB_PATH="/tmp/spoolman_pr_${PR}_data" -URL="http://localhost:${PORT}" - -echo "PR : #${PR}" -echo "Repo Root : ${REPO_ROOT}" -echo "Worktree : ${CURRENT_PWD}" -echo "Branch : ${CURRENT_BRANCH}" -echo "HEAD : ${HEAD_LINE}" -echo "Container : ${CONTAINER}" -echo "Port : ${PORT}" -echo "DB Mount : ${DB_PATH}" -echo "URL : ${URL}" -echo "Strict Mode : ${STRICT}" - -ERRORS=0 -WARNINGS=0 - -if [[ -n "${EXPECTED_WORKTREE}" && "${CURRENT_PWD}" != "${EXPECTED_WORKTREE}" ]]; then - echo "MISMATCH: worktree '${CURRENT_PWD}' != expected '${EXPECTED_WORKTREE}'" >&2 - ERRORS=1 -fi - -if [[ -n "${EXPECTED_BRANCH}" && "${CURRENT_BRANCH}" != "${EXPECTED_BRANCH}" ]]; then - echo "MISMATCH: branch '${CURRENT_BRANCH}' != expected '${EXPECTED_BRANCH}'" >&2 - ERRORS=1 -fi - -if [[ "${CURRENT_BRANCH}" == "HEAD" ]]; then - echo "MISMATCH: detached HEAD detected. Switch to the PR branch before editing." >&2 - ERRORS=1 -fi - -# If no explicit expected values are provided, still surface a context warning when -# neither path nor branch appears to include the PR id. -if [[ -z "${EXPECTED_WORKTREE}" && -z "${EXPECTED_BRANCH}" ]]; then - if ! [[ "${CURRENT_PWD}" =~ ${PR} || "${CURRENT_BRANCH}" =~ ${PR} ]]; then - echo "WARNING: PR id '${PR}' not found in current worktree path or branch name." >&2 - WARNINGS=1 - fi -fi - -if command -v docker >/dev/null 2>&1; then - echo "Docker :" - CONTAINER_LINE="$(docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' | awk -v name="${CONTAINER}" '$1 == name {print $0}')" - if [[ -n "${CONTAINER_LINE}" ]]; then - echo " ${CONTAINER_LINE}" - else - echo " (not running)" - if [[ "${REQUIRE_CONTAINER}" -eq 1 ]]; then - echo "MISMATCH: required container '${CONTAINER}' is not running." >&2 - ERRORS=1 - fi - fi -else - echo "Docker : not found" - if [[ "${REQUIRE_CONTAINER}" -eq 1 ]]; then - echo "MISMATCH: --require-container specified but docker is not available." >&2 - ERRORS=1 - fi -fi - -if [[ "${REQUIRE_URL}" -eq 1 ]]; then - if command -v curl >/dev/null 2>&1; then - HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' "${URL}" || true)" - echo "URL Probe : ${HTTP_CODE}" - if [[ "${HTTP_CODE}" == "000" ]]; then - echo "MISMATCH: URL '${URL}' is not reachable." >&2 - ERRORS=1 - fi - else - echo "URL Probe : curl not found" - echo "MISMATCH: --require-url specified but curl is not available." >&2 - ERRORS=1 - fi -fi - -if [[ "${ERRORS}" -eq 0 ]]; then - if [[ "${WARNINGS}" -eq 0 ]]; then - echo "RESULT : PASS" - else - echo "RESULT : PASS (with warnings)" - fi -else - echo "RESULT : FAIL" -fi - -if [[ "${STRICT}" -eq 1 && "${ERRORS}" -ne 0 ]]; then - echo "ERROR: strict preflight failed." >&2 - exit 2 -fi diff --git a/spoolman/derived_fields.py b/spoolman/derived_fields.py index 419224c9e..3f623a45a 100644 --- a/spoolman/derived_fields.py +++ b/spoolman/derived_fields.py @@ -81,15 +81,18 @@ class DerivedFieldPreviewResponse(BaseModel): def _as_datetime(value: Any) -> datetime: + # Normalize all datetime operands to timezone-aware UTC so interval helpers + # (days_between/hours_between) can safely compare mixed user inputs (with/without timezone). if isinstance(value, datetime): - return value + return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) if isinstance(value, date): - return datetime.combine(value, time.min) + return datetime.combine(value, time.min, tzinfo=timezone.utc) if isinstance(value, str): normalized = value.strip() if normalized.endswith("Z"): normalized = f"{normalized[:-1]}+00:00" - return datetime.fromisoformat(normalized) + parsed = datetime.fromisoformat(normalized) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc) raise ValueError(f"Value {value!r} is not a datetime-compatible input.") @@ -438,6 +441,8 @@ def _validate_expression_payload(expression_json: dict[str, Any]) -> None: def _parse_extra_field_value(value: Any) -> Any: + # Extra-field values are persisted as JSON strings; parse when possible so + # formula operators evaluate real typed values instead of quoted text. if not isinstance(value, str): return value try: @@ -447,6 +452,8 @@ def _parse_extra_field_value(value: Any) -> Any: def _normalize_formula_scope(value: Any) -> Any: + # Normalize nested payloads recursively so derived evaluation sees stable types + # and compatibility aliases regardless of API/UI serialization differences. if isinstance(value, dict): normalized: dict[str, Any] = {} for key, nested in value.items(): @@ -457,6 +464,13 @@ def _normalize_formula_scope(value: Any) -> Any: } continue normalized[key] = _normalize_formula_scope(nested) + + # Preserve both naming conventions so existing formulas written against either + # "registered" or "created_at" keep evaluating across API/UI payloads. + if "registered" in normalized and "created_at" not in normalized: + normalized["created_at"] = normalized["registered"] + if "created_at" in normalized and "registered" not in normalized: + normalized["registered"] = normalized["created_at"] return normalized if isinstance(value, list): return [_normalize_formula_scope(item) for item in value] From dbdbfadbd7ae58c6f8f1d7a5c3bb4a8fdeb1d6b6 Mon Sep 17 00:00:00 2001 From: akira69 Date: Mon, 16 Mar 2026 14:28:09 -0500 Subject: [PATCH 3/6] style: Apply ESLint, Prettier, and Ruff formatting fixes --- .../pages/settings/formulaFieldsSettings.tsx | 329 +++++++++++------- spoolman/derived_fields.py | 8 +- 2 files changed, 202 insertions(+), 135 deletions(-) diff --git a/client/src/pages/settings/formulaFieldsSettings.tsx b/client/src/pages/settings/formulaFieldsSettings.tsx index 952451e83..5e261841b 100644 --- a/client/src/pages/settings/formulaFieldsSettings.tsx +++ b/client/src/pages/settings/formulaFieldsSettings.tsx @@ -10,7 +10,6 @@ import { PlusOutlined, QuestionCircleOutlined, WarningOutlined, - CopyOutlined, } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; import { @@ -215,9 +214,7 @@ function resolveColorLuminance(color: string): number | null { if (hexMatch) { const hex = hexMatch[1]; const value = - hex.length === 3 || hex.length === 4 - ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` - : hex.slice(0, 6); + hex.length === 3 || hex.length === 4 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex.slice(0, 6); const r = parseInt(value.slice(0, 2), 16); const g = parseInt(value.slice(2, 4), 16); const b = parseInt(value.slice(4, 6), 16); @@ -421,11 +418,22 @@ function randomFloatSampleValue(): number { // Randomize datetime sample times so date-diff previews surface fractional values by default. function randomTwoDigitSampleValue(maxExclusive: number): string { - return Math.floor(Math.random() * maxExclusive).toString().padStart(2, "0"); + return Math.floor(Math.random() * maxExclusive) + .toString() + .padStart(2, "0"); } function randomIsoDatetimeSampleValue(baseDate: string): string { - return baseDate + "T" + randomTwoDigitSampleValue(24) + ":" + randomTwoDigitSampleValue(60) + ":" + randomTwoDigitSampleValue(60) + "Z"; + return ( + baseDate + + "T" + + randomTwoDigitSampleValue(24) + + ":" + + randomTwoDigitSampleValue(60) + + ":" + + randomTwoDigitSampleValue(60) + + "Z" + ); } function randomOrderedIntegerRangeSampleValue(): [number, number] { @@ -575,7 +583,11 @@ function inferExpressionJsonType(node: unknown): FormulaResultTypeHint { return "number"; } - if (["date_only", "time_only", "today", "cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes(operator)) { + if ( + ["date_only", "time_only", "today", "cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes( + operator, + ) + ) { return "text"; } @@ -669,13 +681,14 @@ export function FormulaFieldsSettings() { () => ({ display: "grid", // Keep references dense while predictable: 4 columns on desktop, 3/2 on medium widths, 1 on mobile. - gridTemplateColumns: screens.lg || screens.xl || screens.xxl - ? "repeat(4, minmax(0, 1fr))" - : screens.md - ? "repeat(3, minmax(0, 1fr))" - : screens.sm - ? "repeat(2, minmax(0, 1fr))" - : "repeat(1, minmax(0, 1fr))", + gridTemplateColumns: + screens.lg || screens.xl || screens.xxl + ? "repeat(4, minmax(0, 1fr))" + : screens.md + ? "repeat(3, minmax(0, 1fr))" + : screens.sm + ? "repeat(2, minmax(0, 1fr))" + : "repeat(1, minmax(0, 1fr))", gap: 6, }), [screens.lg, screens.md, screens.sm, screens.xl, screens.xxl], @@ -781,10 +794,11 @@ export function FormulaFieldsSettings() { mixBlendMode: "normal", }, // Force one consistent drawn selection color for both focused and blurred states. - ".cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground, &.cm-focused .cm-selectionBackground, &.cm-focused .cm-selectionLayer .cm-selectionBackground": { - backgroundColor: `${selectionColor} !important`, - borderRadius: 2, - }, + ".cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground, &.cm-focused .cm-selectionBackground, &.cm-focused .cm-selectionLayer .cm-selectionBackground": + { + backgroundColor: `${selectionColor} !important`, + borderRadius: 2, + }, // Keep native browser selection transparent so it doesn't override with platform colors. ".cm-content ::selection, .cm-line ::selection, .cm-line > span::selection, .cm-content *::selection": { backgroundColor: "transparent !important", @@ -833,10 +847,7 @@ export function FormulaFieldsSettings() { ], [t], ); - const keyLooksLikeReservedToken = useMemo( - () => RESERVED_DERIVED_KEY_NAMES.has(derivedKeyValue), - [derivedKeyValue], - ); + const keyLooksLikeReservedToken = useMemo(() => RESERVED_DERIVED_KEY_NAMES.has(derivedKeyValue), [derivedKeyValue]); const sampleValuesPlaceholder = SAMPLE_VALUE_PLACEHOLDERS[selectedEntityType]; @@ -1021,7 +1032,9 @@ export function FormulaFieldsSettings() { // tokens that can't accept that selected reference kind. Clearing/completing pending insert // resets all helper tokens back to normal. if (pendingJsonHelperInsert?.selectedOperands.length) { - const selectedReference = pendingJsonHelperInsert.selectedOperands.find((operand) => operand.kind === "reference"); + const selectedReference = pendingJsonHelperInsert.selectedOperands.find( + (operand) => operand.kind === "reference", + ); if (!selectedReference) { return null; } @@ -1100,17 +1113,17 @@ export function FormulaFieldsSettings() {
{operatorGroups.map((group) => { const compactTitle = - group.key === "logical" - ? ( - <> - {t("settings.formula_fields.formula.json_builder.operator_compact.logical_top")} -
- {t("settings.formula_fields.formula.json_builder.operator_compact.logical_bottom")} - - ) - : group.key === "comparison" - ? t("settings.formula_fields.formula.json_builder.operator_compact.comparison") - : t("settings.formula_fields.formula.json_builder.operator_compact.math"); + group.key === "logical" ? ( + <> + {t("settings.formula_fields.formula.json_builder.operator_compact.logical_top")} +
+ {t("settings.formula_fields.formula.json_builder.operator_compact.logical_bottom")} + + ) : group.key === "comparison" ? ( + t("settings.formula_fields.formula.json_builder.operator_compact.comparison") + ) : ( + t("settings.formula_fields.formula.json_builder.operator_compact.math") + ); const operatorGridColumns = group.key === "logical" ? "repeat(2, max-content)" : "repeat(3, max-content)"; const labelColumnWidth = group.key === "logical" ? 90 : 78; return ( @@ -1135,7 +1148,7 @@ export function FormulaFieldsSettings() { justifyContent: "start", }} > - {group.operators.map((operator) => ( + {group.operators.map((operator) => (() => { const tokenId = `operator-${group.key}-${operator}`; const isHovered = hoveredTokenId === tokenId; @@ -1158,17 +1171,29 @@ export function FormulaFieldsSettings() { transition: "all 120ms ease-out", }} onMouseEnter={interactive && !disabled ? () => setHoveredTokenId(tokenId) : undefined} - onMouseLeave={interactive && !disabled ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} + onMouseLeave={ + interactive && !disabled + ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) + : undefined + } onClick={interactive && !disabled ? () => insertExpressionJsonOperator(operator) : undefined} > {operator} ); - })() - ))} + })(), + )}
- + {compactTitle} @@ -1184,11 +1209,7 @@ export function FormulaFieldsSettings() { // helper snippet with those references. Respects helper constraints: insert_mode (none/single/multiple), // reference_count (how many fields the helper needs), value_kind (type checks for compatibility). // Disabled helpers show tooltips explaining why (e.g., "no numeric fields available for math helper"). - const renderHelperTokenCategory = ( - groupKey: string, - interactive: boolean, - compact = false, - ) => { + const renderHelperTokenCategory = (groupKey: string, interactive: boolean, compact = false) => { const group = helperGroupByKey[groupKey]; if (!group || group.helpers.length === 0) { return null; @@ -1216,7 +1237,11 @@ export function FormulaFieldsSettings() { transition: "all 120ms ease-out", }} onMouseEnter={interactive && !disabledReason ? () => setHoveredTokenId(tokenId) : undefined} - onMouseLeave={interactive && !disabledReason ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined} + onMouseLeave={ + interactive && !disabledReason + ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) + : undefined + } onClick={interactive && !disabledReason ? () => insertExpressionJsonHelper(helper) : undefined} > {helper.name} @@ -1502,9 +1527,9 @@ export function FormulaFieldsSettings() { } const snippet = { - [pendingHelperDefinition.name]: selectedOperands.slice(0, requiredReferenceCount).map((operand) => ( - operand.kind === "reference" ? { var: operand.value } : { [operand.value]: [] } - )), + [pendingHelperDefinition.name]: selectedOperands + .slice(0, requiredReferenceCount) + .map((operand) => (operand.kind === "reference" ? { var: operand.value } : { [operand.value]: [] })), }; // Insert ready-to-parse JSON Logic objects so users can build expressions without memorizing // raw AST syntax. Pending helper operands may be refs or helper calls like today(). @@ -1515,7 +1540,12 @@ export function FormulaFieldsSettings() { const insertExpressionJsonHelper = (helper: FormulaHelperDefinition) => { // Treat today() as a valid date-diff operand while a pending helper is collecting // operands, so clicks produce one combined snippet instead of standalone {"today":[]}. - if (pendingHelperDefinition && helper.insert_mode === "none" && helper.name === "today" && pendingHelperDefinition.category === "date_diff") { + if ( + pendingHelperDefinition && + helper.insert_mode === "none" && + helper.name === "today" && + pendingHelperDefinition.category === "date_diff" + ) { const pendingState = pendingJsonHelperInsert; if (!pendingState) { return; @@ -1530,9 +1560,9 @@ export function FormulaFieldsSettings() { return; } const snippet = { - [pendingHelperDefinition.name]: selectedOperands.slice(0, requiredReferenceCount).map((operand) => ( - operand.kind === "reference" ? { var: operand.value } : { [operand.value]: [] } - )), + [pendingHelperDefinition.name]: selectedOperands + .slice(0, requiredReferenceCount) + .map((operand) => (operand.kind === "reference" ? { var: operand.value } : { [operand.value]: [] })), }; // Allow date-diff helpers to consume dynamic today() as an operand instead of inserting it standalone. insertExpressionJsonSnippet(JSON.stringify(snippet, null, 2)); @@ -1706,9 +1736,14 @@ export function FormulaFieldsSettings() { }); messageApi.success( - t(editingDerivedKey ? "settings.formula_fields.formula.messages.updated" : "settings.formula_fields.formula.messages.created", { - name: values.name, - }), + t( + editingDerivedKey + ? "settings.formula_fields.formula.messages.updated" + : "settings.formula_fields.formula.messages.created", + { + name: values.name, + }, + ), ); closeDerivedModal(); } catch (errInfo) { @@ -1742,11 +1777,7 @@ export function FormulaFieldsSettings() { const referenceKind = referenceKindByName[reference] || "unknown"; // Seed new sample keys with type-aware defaults so previews work immediately // and users can adjust values instead of building sample JSON from scratch. - const defaultValue = getSampleDefaultValue( - referenceKind, - reference, - configuredFieldByReference[reference], - ); + const defaultValue = getSampleDefaultValue(referenceKind, reference, configuredFieldByReference[reference]); if (insertReferencePathIfMissing(mergedSampleValues, reference, defaultValue)) { insertedReferences.push(reference); trackedAutoReferences.add(reference); @@ -1762,49 +1793,56 @@ export function FormulaFieldsSettings() { // Execute preview with request sequencing so stale async responses never overwrite // newer editor state while users are typing quickly. - const runPreview = useCallback(async (showMessageOnError: boolean) => { - const requestId = previewRequestRef.current + 1; - previewRequestRef.current = requestId; - try { - const sampleValues = parseSampleValues((derivedForm.getFieldValue("sample_values") as string | undefined) || "{}"); - const expressionJson = parseExpressionJson(derivedForm.getFieldValue("expression_json") as string | undefined); - if (!expressionJson) { - throw new Error(t("settings.formula_fields.formula.expression_json_required")); - } - // Preview uses sample JSON only as a sandbox for validating formulas before they are exposed - // on show/list/template surfaces. - const preview = await previewDerivedField.mutateAsync({ - expression_json: expressionJson, - sample_values: sampleValues, - }); + const runPreview = useCallback( + async (showMessageOnError: boolean) => { + const requestId = previewRequestRef.current + 1; + previewRequestRef.current = requestId; + try { + const sampleValues = parseSampleValues( + (derivedForm.getFieldValue("sample_values") as string | undefined) || "{}", + ); + const expressionJson = parseExpressionJson(derivedForm.getFieldValue("expression_json") as string | undefined); + if (!expressionJson) { + throw new Error(t("settings.formula_fields.formula.expression_json_required")); + } + // Preview uses sample JSON only as a sandbox for validating formulas before they are exposed + // on show/list/template surfaces. + const preview = await previewDerivedField.mutateAsync({ + expression_json: expressionJson, + sample_values: sampleValues, + }); - if (requestId !== previewRequestRef.current) { - return; - } - setPreviewText(formatPreviewValue(preview.result)); - setPreviewErrorText(null); - } catch (errInfo) { - if (requestId !== previewRequestRef.current) { - return; - } - setPreviewText(null); - if (errInfo instanceof Error) { - setPreviewErrorText(errInfo.message); - } else { - setPreviewErrorText(t("settings.formula_fields.formula.preview.error_fallback")); - } - if (showMessageOnError && errInfo instanceof Error) { - messageApi.error(errInfo.message); + if (requestId !== previewRequestRef.current) { + return; + } + setPreviewText(formatPreviewValue(preview.result)); + setPreviewErrorText(null); + } catch (errInfo) { + if (requestId !== previewRequestRef.current) { + return; + } + setPreviewText(null); + if (errInfo instanceof Error) { + setPreviewErrorText(errInfo.message); + } else { + setPreviewErrorText(t("settings.formula_fields.formula.preview.error_fallback")); + } + if (showMessageOnError && errInfo instanceof Error) { + messageApi.error(errInfo.message); + } } - } - }, [derivedForm, messageApi, previewDerivedField, t]); + }, + [derivedForm, messageApi, previewDerivedField, t], + ); // Apply one synchronization pass between expression refs and sample JSON. // The pass is non-destructive for user-owned keys while still cleaning stale auto-managed refs. const syncMissingSampleValueKeys = (showMessageOnError: boolean) => { let currentSampleValues: Record; try { - currentSampleValues = parseSampleValues((derivedForm.getFieldValue("sample_values") as string | undefined) || "{}"); + currentSampleValues = parseSampleValues( + (derivedForm.getFieldValue("sample_values") as string | undefined) || "{}", + ); } catch (errInfo) { if (showMessageOnError && errInfo instanceof Error) { messageApi.warning(errInfo.message); @@ -1813,7 +1851,8 @@ export function FormulaFieldsSettings() { } // Apply additive scaffolding and dead-key pruning without touching user-owned sample keys. - const { mergedSampleValues, insertedReferences, removedReferences } = buildSampleValuesWithMissingReferences(currentSampleValues); + const { mergedSampleValues, insertedReferences, removedReferences } = + buildSampleValuesWithMissingReferences(currentSampleValues); if (insertedReferences.length === 0 && removedReferences.length === 0) { return true; @@ -1832,7 +1871,7 @@ export function FormulaFieldsSettings() { return; } - if (((expressionJsonValue || "").trim()) === "") { + if ((expressionJsonValue || "").trim() === "") { autoManagedSampleReferencesRef.current.clear(); const currentSampleValuesRaw = (derivedForm.getFieldValue("sample_values") as string | undefined) || ""; if (currentSampleValuesRaw.trim() !== "{}") { @@ -1865,7 +1904,7 @@ export function FormulaFieldsSettings() { return; } - if (((expressionJsonValue || "").trim()) === "") { + if ((expressionJsonValue || "").trim() === "") { setPreviewText(null); setPreviewErrorText(t("settings.formula_fields.formula.expression_json_required")); return; @@ -2086,10 +2125,7 @@ export function FormulaFieldsSettings() { pagination={false} locale={{ emptyText: ( - + ), }} onRow={(record) => { @@ -2151,7 +2187,7 @@ export function FormulaFieldsSettings() { "settings.formula_fields.formula.tooltips.key", )} name="key" - extra={( + extra={ {t("settings.formula_fields.formula.key_usage_help")}:{" "} @@ -2164,7 +2200,7 @@ export function FormulaFieldsSettings() { )} - )} + } rules={[ { required: true, min: 1, max: 64, pattern: /^[a-z0-9_]+$/ }, { @@ -2199,7 +2235,11 @@ export function FormulaFieldsSettings() { - + field.key)); - const missingFields = referencedCustomFields.filter((fieldKey) => !availableCustomFields.has(fieldKey)); + const missingFields = referencedCustomFields.filter( + (fieldKey) => !availableCustomFields.has(fieldKey), + ); if (missingFields.length > 0) { throw new Error( t("settings.formula_fields.formula.missing_references", { references: missingFields.join(", ") }), @@ -2322,7 +2364,8 @@ export function FormulaFieldsSettings() { // Ignore no-op sync events where CodeMirror re-emits the same text that is // already in the form model. This prevents guided IF/operator state from // being canceled before the user clicks the next required token. - const currentExpressionValue = (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + const currentExpressionValue = + (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; if (value === currentExpressionValue) { return; } @@ -2338,12 +2381,7 @@ export function FormulaFieldsSettings() { /> {/* Keep editor action controls anchored under the expression editor. */} - + @@ -2539,18 +2580,33 @@ export function FormulaFieldsSettings() { fontWeight: 500, color: isSelectedForPendingHelper ? token.colorPrimaryText - : (!disabledReason && hoveredTokenId === `reference-${reference.value}` ? token.colorWarningText : undefined), + : !disabledReason && hoveredTokenId === `reference-${reference.value}` + ? token.colorWarningText + : undefined, background: - !disabledReason && hoveredTokenId === `reference-${reference.value}` ? token.colorWarningBg : undefined, + !disabledReason && hoveredTokenId === `reference-${reference.value}` + ? token.colorWarningBg + : undefined, borderColor: !disabledReason && hoveredTokenId === `reference-${reference.value}` ? token.colorWarningBorder : undefined, transition: "all 120ms ease-out", }} - onMouseEnter={!disabledReason ? () => setHoveredTokenId(`reference-${reference.value}`) : undefined} - onMouseLeave={!disabledReason ? () => setHoveredTokenId((current) => (current === `reference-${reference.value}` ? null : current)) : undefined} - onClick={!disabledReason ? () => insertExpressionJsonReference(reference.value) : undefined} + onMouseEnter={ + !disabledReason ? () => setHoveredTokenId(`reference-${reference.value}`) : undefined + } + onMouseLeave={ + !disabledReason + ? () => + setHoveredTokenId((current) => + current === `reference-${reference.value}` ? null : current, + ) + : undefined + } + onClick={ + !disabledReason ? () => insertExpressionJsonReference(reference.value) : undefined + } > {reference.label} @@ -2559,13 +2615,20 @@ export function FormulaFieldsSettings() { // do not cause reflow when helper compatibility changes. const content = ( - {referenceToken} + + {referenceToken} + ); return (
{content}
@@ -2584,10 +2647,7 @@ export function FormulaFieldsSettings() { rules={[ { validator: async (_, value) => { - parseSampleValues( - value, - t("settings.formula_fields.formula.sample_values_invalid"), - ); + parseSampleValues(value, t("settings.formula_fields.formula.sample_values_invalid")); }, }, ]} @@ -2597,7 +2657,10 @@ export function FormulaFieldsSettings() {
- {labeledField("settings.formula_fields.formula.sample_values", "settings.formula_fields.formula.tooltips.sample_values")} + {labeledField( + "settings.formula_fields.formula.sample_values", + "settings.formula_fields.formula.tooltips.sample_values", + )} Auto-update {reference} diff --git a/spoolman/derived_fields.py b/spoolman/derived_fields.py index 3f623a45a..0d8c396b5 100644 --- a/spoolman/derived_fields.py +++ b/spoolman/derived_fields.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) + class DerivedFieldType(Enum): """Supported output types for a derived field.""" @@ -459,8 +460,7 @@ def _normalize_formula_scope(value: Any) -> Any: for key, nested in value.items(): if key == "extra" and isinstance(nested, dict): normalized[key] = { - extra_key: _parse_extra_field_value(extra_value) - for extra_key, extra_value in nested.items() + extra_key: _parse_extra_field_value(extra_value) for extra_key, extra_value in nested.items() } continue normalized[key] = _normalize_formula_scope(nested) @@ -593,7 +593,9 @@ async def resolve_include_derived_in_api(db: AsyncSession, include_derived: bool return default_value -async def add_or_update_derived_field(db: AsyncSession, entity_type: EntityType, derived_field: DerivedFieldDefinition) -> None: +async def add_or_update_derived_field( + db: AsyncSession, entity_type: EntityType, derived_field: DerivedFieldDefinition +) -> None: """Create or update a derived field.""" _validate_expression_payload(derived_field.expression_json) From 40151ff203442d8f0d923baeb3ea1d4178d6d40e Mon Sep 17 00:00:00 2001 From: akira69 Date: Sun, 29 Mar 2026 06:51:14 -0500 Subject: [PATCH 4/6] fix formula field validation and dependency handling --- JSON_LOGIC_SPIKE.md | 177 ---------- client/public/locales/en/common.json | 6 +- .../pages/settings/extraFieldsSettings.tsx | 71 ++-- .../pages/settings/formulaFieldsSettings.tsx | 168 ++++++++-- client/src/utils/formulaFields.ts | 129 +++++++- client/src/utils/queryFields.ts | 13 +- scripts/json_logic_parity.py | 304 ------------------ spoolman/api/v1/field.py | 13 +- spoolman/api/v1/filament.py | 11 +- spoolman/api/v1/spool.py | 11 +- spoolman/api/v1/vendor.py | 11 +- spoolman/derived_fields.py | 282 +++++++++++++++- spoolman/extra_fields.py | 27 +- spoolman/formula_references.py | 51 +++ .../fields/json_logic_parity_fixtures.json | 142 -------- .../tests/fields/test_derived.py | 53 +++ .../tests/fields/test_derived_api.py | 46 ++- 17 files changed, 791 insertions(+), 724 deletions(-) delete mode 100644 JSON_LOGIC_SPIKE.md delete mode 100644 scripts/json_logic_parity.py create mode 100644 spoolman/formula_references.py delete mode 100644 tests_integration/tests/fields/json_logic_parity_fixtures.json diff --git a/JSON_LOGIC_SPIKE.md b/JSON_LOGIC_SPIKE.md deleted file mode 100644 index 44af8cae2..000000000 --- a/JSON_LOGIC_SPIKE.md +++ /dev/null @@ -1,177 +0,0 @@ -# JSON Logic Spike (Clean-Cut) for Formula Extra Fields - -## Status -- Owner: TBD -- Branch: `feat/complex-fields-framework` (PR #874 context) -- Date: 2026-03-05 -- Decision type: Spike RFC (implementation-first, no legacy compatibility) - -## Background -Current formula fields use a custom expression syntax/evaluator with limited typing and helper coverage. -We want richer boolean/date/text logic and safer execution semantics without continuing ad-hoc parser growth. - -Relevant requests: -- [#795](https://github.com/Donkie/Spoolman/issues/795) Labels: Date formatting -- [#853](https://github.com/Donkie/Spoolman/issues/853) Sort by hue -- [#870](https://github.com/Donkie/Spoolman/issues/870) Show empty spool weight column -- [#783](https://github.com/Donkie/Spoolman/issues/783) Extra action buttons per spool (partially related) - -## Goals -- Replace the formula expression engine with JSON Logic for formula extra fields. -- Support result types: `number`, `text`, `boolean`, `date`, `datetime`, `time`. -- Enable richer operators: logical, comparison, conditional, string, and date helpers. -- Enforce frontend/backend evaluation parity for previews and rendered values. -- Keep execution safe and deterministic (no unrestricted eval). - -## Non-Goals -- Backward compatibility with old formula syntax. -- Automatic migration of existing formula definitions. -- Replacing complex extra fields that add custom UI/actions/workflows. -- Server-side indexed filtering/sorting on computed values in this spike. - -## Decision (Proposed) -- Use JSON Logic as the canonical formula representation. -- Store formulas as JSON AST only. -- Remove legacy expression parser/evaluator once spike implementation is accepted. - -## Runtime Candidate Snapshot (2026-03-05) -| Candidate | Role | License | Activity signal | Notes | -| --- | --- | --- | --- | --- | -| `json-logic/json-logic-engine` | Frontend runtime | MIT | pushed 2026-01-21 | Active JS implementation with custom operator support. | -| `jwadhams/json-logic-js` | Frontend/runtime reference | MIT | pushed 2024-07-09 | Canonical older implementation, broader adoption but slower recent change pace. | -| `nadirizr/json-logic-py` | Backend runtime | MIT | pushed 2023-12-19 | Usable baseline, but older activity and parity risk with modern JS engines. | -| `llnl/jsonlogic` | Python tooling (non-evaluator) | MIT | pushed 2026-03-05 | Provides JSON Logic expression generation helpers, not a direct evaluator runtime. | -| `cloud-custodian/cel-python` | Alternative (non-JSON Logic) | Apache-2.0 | pushed 2026-02-17 | Strong typed alternative if JSON Logic parity fails. | - -Proposed spike baseline: -- Frontend: `json-logic-engine` -- Backend: start with `nadirizr/json-logic-py` for evaluator parity harness; reassess additional evaluator candidates after fixture runs. - -## Proposed Architecture -### Data model -- `DerivedFieldDefinition` stores: -- `result_type` -- `expression_json` (JSON object, required) -- Keep current surfaces/column toggle behavior unchanged. - -### Backend -- Add a JSON Logic evaluator wrapper in Python. -- Provide a strict operator allowlist. -- Add custom operators for Spoolman domain helpers: -- `today`, `date_only`, `time_only`, `days_between`, `hours_between`, `hue_from_hex`, `coalesce` -- Validate AST at save-time with: -- operator allowlist checks -- reference format checks -- result type compatibility checks -- Preview endpoint accepts JSON AST and sample values. - -### Frontend -- Formula editor becomes JSON Logic editor: -- raw JSON textarea in spike phase -- clickable chips for references/operators -- preview panel unchanged in behavior -- Type-aware helper palette by selected `result_type`. -- Keep current list/show/template surface controls. - -## Operator/Helper Catalog (Initial) -### Logical and conditional -- `and`, `or`, `!`, `if`, `??` (or `coalesce`) - -### Comparison -- `==`, `!=`, `<`, `<=`, `>`, `>=` - -### Numeric -- `+`, `-`, `*`, `/`, `%`, `abs`, `min`, `max`, `round`, `floor`, `ceil` - -### Text -- `cat`, `substr`, `lower`, `upper`, `trim`, `length`, `replace` - -### Date and time -- `today`, `year`, `month`, `day`, `hour`, `minute`, `second`, `timestamp` -- `date_only`, `time_only`, `days_between`, `hours_between` - -## Result Type Rules (Draft) -- `number`: numeric expressions only. -- `text`: string output or explicit stringify. -- `boolean`: logical/comparison output. -- `date`: ISO `YYYY-MM-DD`. -- `datetime`: ISO datetime string in UTC. -- `time`: ISO `HH:MM:SS`. - -Validation should fail early when inferred output does not match `result_type`. - -## Scope Boundaries vs Complex Extra Fields -JSON Logic formula fields can cover: -- computed columns -- status flags -- derived display values - -Complex extra fields remain for: -- UI actions/buttons/workflows -- module-defined interactions beyond scalar value computation - -## Implementation Plan -1. Backend foundation -- Add evaluator wrapper and allowlisted custom ops. -- Add AST validation and type checks. -- Update preview endpoint to JSON AST payload. - -2. Frontend foundation -- Replace expression editor with JSON AST editor UI. -- Add reference/operator chip insertion. -- Keep preview UX and surface controls. - -3. Integration -- Replace runtime formula evaluation in list/show/template render paths. -- Remove old formula parser/evaluator modules. - -4. Tests -- Backend unit tests for ops, validation, and type enforcement. -- Frontend tests for insertion and preview payload shape. -- Parity tests using shared fixtures for FE and BE outputs. - -## Risks -- JSON authoring UX can be heavy without a visual builder. -- FE/BE library semantic differences must be normalized. -- Date/time coercion edge cases can be surprising if not tightly specified. - -## Immediate Next Step Checklist (Phase 0, 1-2 days) -- Build a 20-case parity fixture set (`tests_integration` + frontend fixture file): -- arithmetic, boolean logic, null/coalesce, string ops, date helpers, hue helper, invalid syntax, invalid type. -- Run fixture set against two backend candidates and one frontend candidate. -- Record mismatches with exact operator semantics and type coercion behavior. -- Select backend runtime and lock the operator subset for v1. -- Freeze UTC/date behavior in writing (`today` allowed, `now` deferred). -- Finalize API contract for preview/save payload (`expression_json` only). - -## Phase 0 Snapshot (2026-03-05) -- Fixture set created: `tests_integration/tests/fields/json_logic_parity_fixtures.json` (20 cases). -- Runner created: `scripts/json_logic_parity.py`. -- Backend execution baseline: -- Engine: `json-logic-py` (sourced directly from GitHub due blocked PyPI access in this environment) -- Result: **20/20 pass** -- Meaning: operator set and custom helper wiring in the harness are viable for spike phase. - -## Phase 1 Snapshot (2026-03-05) -- Backend API accepts `expression_json` for preview/save with allowlisted JSON Logic operators and custom helpers. -- Frontend formula runtime evaluates `expression_json` when present, with legacy string expressions as fallback. -- Settings dependency checks now resolve custom-field references from both legacy expressions and JSON Logic `var` nodes. -- Formula editor accepts an optional `Expression JSON (JSON Logic)` payload for preview/save during transition. - -## Spike Exit Criteria -- At least 15 golden fixture expressions pass identically in FE and BE. -- Save-time validation blocks invalid operators/references/types. -- Preview and runtime rendering are consistent for all result types. -- Old expression engine fully removable without hidden dependencies. - -## Open Questions -- Which Python JSON Logic library will be used, and does it support needed custom ops cleanly? -- Should `now()` be excluded initially to avoid time-volatile values in displayed columns? -- Do we allow nested object outputs at all, or scalar-only forever? -- Should date/time helpers always be UTC-only in v1? - -## Deliverables -- `JSON_LOGIC_SPIKE.md` (this RFC) -- Prototype backend evaluator + validation -- Prototype frontend JSON editor + preview -- Test report with parity matrix and go/no-go recommendation diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index f4ef28375..f4a3aa318 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -342,7 +342,8 @@ "order": "Order", "default_value": "Default Value", "choices": "Choices", - "multi_choice": "Multi Choice" + "multi_choice": "Multi Choice", + "referenced_in": "Referenced In" }, "field_type": { "text": "Text", @@ -361,6 +362,9 @@ "key_not_changed": "Please change the key to something else.", "delete_confirm": "Delete field {{name}}?", "delete_confirm_description": "This will delete the field and all associated data for all entities.", + "referenced_in_none": "None", + "referenced_in_count": "{{count}} formula field", + "referenced_in_count_other": "{{count}} formula fields", "delete_dependency_warning_intro": "Deleting this custom field will make dependent fields inoperable:", "delete_dependency_warning_formula": "Formula extra fields: {{dependencies}}", "delete_dependency_warning_footer": "These entries remain saved, but behavior depending on this field will fail until references are updated." diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index b8b12b62c..8f5202d36 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -13,6 +13,7 @@ import { Select, Space, Table, + Tooltip, Typography, message, theme, @@ -557,6 +558,25 @@ export function ExtraFieldsSettings() { }, width: "10%", }, + { + title: t("settings.extra_fields.params.referenced_in"), + key: "referenced_in", + render: (_: unknown, record: FieldHolder) => { + const formulaDependencies = formulaDependenciesByCustomFieldKey[record.field.key] || []; + if (formulaDependencies.length === 0) { + return {t("settings.extra_fields.referenced_in_none")}; + } + const formulaDependencyList = formulaDependencies.map((item) => `${item.name} (${item.key})`).join(", "); + return ( + + + {t("settings.extra_fields.referenced_in_count", { count: formulaDependencies.length })} + + + ); + }, + width: "12%", + }, { title: "", dataIndex: "operation", @@ -580,13 +600,17 @@ export function ExtraFieldsSettings() { {(() => { const formulaDependencies = formulaDependenciesByCustomFieldKey[record.field.key] || []; const hasFormulaDependencies = formulaDependencies.length > 0; - const formulaDependencyList = formulaDependencies.map((item) => `${item.name} (${item.key})`).join(", "); + const formulaDependencyList = formulaDependencies + .map((item) => `${item.name} (${item.key})`) + .join(", "); const confirmDescription = hasFormulaDependencies ? ( {t("settings.extra_fields.delete_confirm_description", { name: record.field.name })} - {t("settings.extra_fields.delete_dependency_warning_intro")} + + {t("settings.extra_fields.delete_dependency_warning_intro")} + {hasFormulaDependencies && ( {t("settings.extra_fields.delete_dependency_warning_formula", { @@ -594,25 +618,31 @@ export function ExtraFieldsSettings() { })} )} - {t("settings.extra_fields.delete_dependency_warning_footer")} + + {t("settings.extra_fields.delete_dependency_warning_footer")} + ) : ( t("settings.extra_fields.delete_confirm_description", { name: record.field.name }) ); return ( - del(record.field)} - disabled={editingKey !== ""} - okText={t("buttons.delete")} - cancelText={t("buttons.cancel")} - > - - + + + del(record.field)} + disabled={editingKey !== "" || hasFormulaDependencies} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + + + + ); })()} @@ -664,12 +694,11 @@ export function ExtraFieldsSettings() { {t("settings.extra_fields.custom.header")}: {niceName} - {t("settings.extra_fields.custom.description_intro")} ( - text, integer,{" "} - integer_range, float,{" "} - float_range, datetime,{" "} - boolean, choice).{" "} - {t("settings.extra_fields.custom.description_immutability")} + {t("settings.extra_fields.custom.description_intro")} (text,{" "} + integer, integer_range,{" "} + float, float_range,{" "} + datetime, boolean,{" "} + choice). {t("settings.extra_fields.custom.description_immutability")}
= { - vendor: ["id", "name", "registered", "comment"], - filament: ["id", "name", "material", "price", "density", "weight", "color_hex", "comment", "registered"], - spool: ["id", "weight", "remaining_weight", "used_weight", "price", "lot_nr", "comment", "registered"], + vendor: ["id", "registered", "created_at", "name", "comment", "empty_spool_weight", "external_id"], + filament: [ + "id", + "registered", + "created_at", + "name", + "material", + "price", + "density", + "diameter", + "weight", + "spool_weight", + "article_number", + "comment", + "settings_extruder_temp", + "settings_bed_temp", + "color_hex", + "multi_color_hexes", + "multi_color_direction", + "external_id", + "vendor.id", + "vendor.registered", + "vendor.created_at", + "vendor.name", + "vendor.comment", + "vendor.empty_spool_weight", + "vendor.external_id", + ], + spool: [ + "id", + "weight", + "registered", + "created_at", + "first_used", + "last_used", + "price", + "initial_weight", + "spool_weight", + "remaining_weight", + "used_weight", + "remaining_length", + "used_length", + "location", + "lot_nr", + "comment", + "archived", + "filament.id", + "filament.registered", + "filament.created_at", + "filament.name", + "filament.material", + "filament.price", + "filament.density", + "filament.diameter", + "filament.weight", + "filament.spool_weight", + "filament.article_number", + "filament.comment", + "filament.settings_extruder_temp", + "filament.settings_bed_temp", + "filament.color_hex", + "filament.multi_color_hexes", + "filament.multi_color_direction", + "filament.external_id", + "filament.vendor.id", + "filament.vendor.registered", + "filament.vendor.created_at", + "filament.vendor.name", + "filament.vendor.comment", + "filament.vendor.empty_spool_weight", + "filament.vendor.external_id", + ], }; const SAMPLE_VALUE_PLACEHOLDERS: Record = { vendor: '{"name": "Example Vendor", "registered": "2026-02-28T10:15:00Z"}', filament: '{"weight": 482.36, "material": "PLA", "registered": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}', - spool: '{"weight": 482.36, "remaining_weight": 225.12, "registered": "2026-02-28T10:15:00Z"}', + spool: + '{"weight": 482.36, "remaining_weight": 225.12, "registered": "2026-02-28T10:15:00Z", "filament": {"weight": 1000, "price": 24.99, "color_hex": "#FF00FF", "vendor": {"name": "Example Vendor"}}}', +}; +const EXTRA_REFERENCE_PREFIXES: Record = { + vendor: ["extra."], + filament: ["extra.", "vendor.extra."], + spool: ["extra.", "filament.extra.", "filament.vendor.extra."], }; const JSON_LOGIC_OPERATOR_GROUPS: Array<{ key: string; operators: string[] }> = [ { key: "logical", operators: ["if", "and", "or", "!"] }, @@ -154,7 +229,7 @@ type PendingHelperHintState = { allowHelperOnly: boolean; stepLabelKey?: string; }; -type FormulaResultTypeHint = "number" | "text" | "boolean" | "unknown"; +type FormulaResultTypeHint = "number" | "text" | "boolean" | "date" | "datetime" | "time" | "unknown"; // Resolve the current IF guided-insert prompt step so the yellow helper hint can // explicitly tell users what token click is expected next. @@ -583,13 +658,15 @@ function inferExpressionJsonType(node: unknown): FormulaResultTypeHint { return "number"; } - if ( - ["date_only", "time_only", "today", "cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes( - operator, - ) - ) { + if (["cat", "concat", "replace", "trim", "upper", "lower", "left", "right"].includes(operator)) { return "text"; } + if (["date_only", "today"].includes(operator)) { + return "date"; + } + if (operator === "time_only") { + return "time"; + } return "unknown"; } @@ -601,6 +678,18 @@ function toDerivedFieldType(typeHint: FormulaResultTypeHint): DerivedFieldType | if (typeHint === "text") { return DerivedFieldType.text; } + if (typeHint === "boolean") { + return DerivedFieldType.boolean; + } + if (typeHint === "date") { + return DerivedFieldType.date; + } + if (typeHint === "datetime") { + return DerivedFieldType.datetime; + } + if (typeHint === "time") { + return DerivedFieldType.time; + } return null; } @@ -824,6 +913,8 @@ export function FormulaFieldsSettings() { ]); const derivedFields = useGetDerivedFields(selectedEntityType); const configuredFields = useGetFields(selectedEntityType); + const filamentConfiguredFields = useGetFields(EntityType.filament); + const vendorConfiguredFields = useGetFields(EntityType.vendor); const setDerivedField = useSetDerivedField(selectedEntityType); const deleteDerivedField = useDeleteDerivedField(selectedEntityType); const previewDerivedField = usePreviewDerivedField(selectedEntityType); @@ -861,17 +952,35 @@ export function FormulaFieldsSettings() { ); const referenceOptions = useMemo(() => { - const extraReferences = (configuredFields.data || []).map((field) => `extra.${field.key}`); + const extraReferenceGroups: string[] = []; + EXTRA_REFERENCE_PREFIXES[selectedEntityType].forEach((prefix) => { + if (prefix === "extra.") { + (configuredFields.data || []).forEach((field) => extraReferenceGroups.push(`${prefix}${field.key}`)); + } else if (prefix === "filament.extra.") { + (filamentConfiguredFields.data || []).forEach((field) => extraReferenceGroups.push(`${prefix}${field.key}`)); + } else if (prefix === "filament.vendor.extra." || prefix === "vendor.extra.") { + (vendorConfiguredFields.data || []).forEach((field) => extraReferenceGroups.push(`${prefix}${field.key}`)); + } + }); // Suggest both built-in fields and configured extra fields so users can compose formulas // without memorizing the exact reference syntax for each entity. - return [...new Set([...BUILTIN_REFERENCE_SUGGESTIONS[selectedEntityType], ...extraReferences])]; - }, [configuredFields.data, selectedEntityType]); + return [...new Set([...BUILTIN_REFERENCE_SUGGESTIONS[selectedEntityType], ...extraReferenceGroups])]; + }, [configuredFields.data, filamentConfiguredFields.data, selectedEntityType, vendorConfiguredFields.data]); const configuredFieldByReference = useMemo( () => - Object.fromEntries( - (configuredFields.data || []).map((field) => [`extra.${field.key}`, field] as const), - ) as Record, - [configuredFields.data], + ({ + ...Object.fromEntries((configuredFields.data || []).map((field) => [`extra.${field.key}`, field] as const)), + ...Object.fromEntries( + (filamentConfiguredFields.data || []).map((field) => [`filament.extra.${field.key}`, field] as const), + ), + ...Object.fromEntries( + (vendorConfiguredFields.data || []).map((field) => [`vendor.extra.${field.key}`, field] as const), + ), + ...Object.fromEntries( + (vendorConfiguredFields.data || []).map((field) => [`filament.vendor.extra.${field.key}`, field] as const), + ), + }) as Record, + [configuredFields.data, filamentConfiguredFields.data, vendorConfiguredFields.data], ); const compactReferenceOptions = useMemo( () => @@ -905,6 +1014,20 @@ export function FormulaFieldsSettings() { return null; } }, [sampleValuesValue]); + const currentDerivedResultType = useMemo(() => { + const inferredType = parsedExpressionJson + ? toDerivedFieldType(inferExpressionJsonType(parsedExpressionJson)) + : null; + if (inferredType) { + return inferredType; + } + if (editingDerivedKey) { + return ( + derivedFields.data?.find((field) => field.key === editingDerivedKey)?.result_type ?? DerivedFieldType.number + ); + } + return DerivedFieldType.number; + }, [derivedFields.data, editingDerivedKey, parsedExpressionJson]); const missingSampleValueReferences = useMemo(() => { if (!parsedSampleValues) { return [] as string[]; @@ -1714,18 +1837,12 @@ export function FormulaFieldsSettings() { } // Keep backend contract intact without exposing Result Type controls in the editor: // infer from JSON when possible, otherwise preserve existing type (edit) or default new fields. - const inferredType = toDerivedFieldType(inferExpressionJsonType(expressionJson)); - const existingType = editingDerivedKey - ? derivedFields.data?.find((field) => field.key === editingDerivedKey)?.result_type - : undefined; - const persistedResultType = inferredType ?? existingType ?? DerivedFieldType.number; - await setDerivedField.mutateAsync({ key, params: { name: values.name, description: values.description || undefined, - result_type: persistedResultType, + result_type: currentDerivedResultType, expression_json: expressionJson, surfaces: values.surfaces, // List-surface formula fields are always hideable through Hide Columns. Persist this @@ -1810,6 +1927,7 @@ export function FormulaFieldsSettings() { const preview = await previewDerivedField.mutateAsync({ expression_json: expressionJson, sample_values: sampleValues, + result_type: currentDerivedResultType, }); if (requestId !== previewRequestRef.current) { @@ -1832,7 +1950,7 @@ export function FormulaFieldsSettings() { } } }, - [derivedForm, messageApi, previewDerivedField, t], + [currentDerivedResultType, derivedForm, messageApi, previewDerivedField, t], ); // Apply one synchronization pass between expression refs and sample JSON. diff --git a/client/src/utils/formulaFields.ts b/client/src/utils/formulaFields.ts index f94c20d26..254ea79a7 100644 --- a/client/src/utils/formulaFields.ts +++ b/client/src/utils/formulaFields.ts @@ -20,26 +20,106 @@ export type FormulaHelperGroupDefinition = { export const FORMULA_HELPERS: FormulaHelperDefinition[] = [ { name: "abs", description: "Returns the absolute value of a number.", category: "math", reference_kind: "number" }, - { name: "min", description: "Returns the smallest value from the provided arguments.", category: "math", reference_kind: "number" }, - { name: "max", description: "Returns the largest value from the provided arguments.", category: "math", reference_kind: "number" }, - { name: "round", description: "Rounds a numeric value to the nearest integer.", category: "math", reference_kind: "number" }, - { name: "coalesce", description: "Returns the first argument that is not null/undefined.", category: "math", reference_kind: "any" }, + { + name: "min", + description: "Returns the smallest value from the provided arguments.", + category: "math", + reference_kind: "number", + }, + { + name: "max", + description: "Returns the largest value from the provided arguments.", + category: "math", + reference_kind: "number", + }, + { + name: "round", + description: "Rounds a numeric value to the nearest integer.", + category: "math", + reference_kind: "number", + }, + { + name: "coalesce", + description: "Returns the first argument that is not null/undefined.", + category: "math", + reference_kind: "any", + }, { name: "cat", description: "Concatenates values as text.", category: "text", reference_kind: "any" }, { name: "upper", description: "Converts text to uppercase.", category: "text", reference_kind: "text" }, { name: "lower", description: "Converts text to lowercase.", category: "text", reference_kind: "text" }, - { name: "trim", description: "Removes leading/trailing whitespace from text.", category: "text", reference_kind: "text" }, + { + name: "trim", + description: "Removes leading/trailing whitespace from text.", + category: "text", + reference_kind: "text", + }, { name: "length", description: "Returns text length.", category: "text", reference_kind: "text" }, - { name: "left", description: "Returns left-most text characters (optional count, default 1).", category: "text", reference_kind: "text" }, - { name: "right", description: "Returns right-most text characters (optional count, default 1).", category: "text", reference_kind: "text" }, - { name: "year", description: "Extracts UTC year from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, - { name: "month", description: "Extracts UTC month (1-12) from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, - { name: "day", description: "Extracts UTC day-of-month from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, - { name: "hour", description: "Extracts UTC hour from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, - { name: "minute", description: "Extracts UTC minute from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, - { name: "second", description: "Extracts UTC second from a date/datetime value.", category: "datetime", reference_kind: "datetime" }, - { name: "timestamp", description: "Converts a date/datetime value to Unix timestamp seconds.", category: "datetime", reference_kind: "datetime" }, - { name: "date_only", description: "Formats a date/datetime as YYYY-MM-DD (UTC).", category: "datetime", reference_kind: "datetime" }, - { name: "time_only", description: "Formats a date/datetime as HH:MM:SS (UTC).", category: "datetime", reference_kind: "datetime" }, + { + name: "left", + description: "Returns left-most text characters (optional count, default 1).", + category: "text", + reference_kind: "text", + }, + { + name: "right", + description: "Returns right-most text characters (optional count, default 1).", + category: "text", + reference_kind: "text", + }, + { + name: "year", + description: "Extracts UTC year from a date/datetime value.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "month", + description: "Extracts UTC month (1-12) from a date/datetime value.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "day", + description: "Extracts UTC day-of-month from a date/datetime value.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "hour", + description: "Extracts UTC hour from a date/datetime value.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "minute", + description: "Extracts UTC minute from a date/datetime value.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "second", + description: "Extracts UTC second from a date/datetime value.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "timestamp", + description: "Converts a date/datetime value to Unix timestamp seconds.", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "date_only", + description: "Formats a date/datetime as YYYY-MM-DD (UTC).", + category: "datetime", + reference_kind: "datetime", + }, + { + name: "time_only", + description: "Formats a date/datetime as HH:MM:SS (UTC).", + category: "datetime", + reference_kind: "datetime", + }, { name: "days_between", description: "Returns day difference between start and end date/datetime values.", @@ -63,7 +143,14 @@ export const FORMULA_HELPERS: FormulaHelperDefinition[] = [ { name: "today", description: "Returns current UTC date as YYYY-MM-DD.", category: "dynamic", insert_mode: "none" }, ]; -export const FORMULA_HELPER_GROUP_ORDER: FormulaHelperCategory[] = ["math", "text", "datetime", "dynamic", "date_diff", "color"]; +export const FORMULA_HELPER_GROUP_ORDER: FormulaHelperCategory[] = [ + "math", + "text", + "datetime", + "dynamic", + "date_diff", + "color", +]; export const FORMULA_HELPER_GROUPS: FormulaHelperGroupDefinition[] = FORMULA_HELPER_GROUP_ORDER.map((key) => ({ key, @@ -212,6 +299,14 @@ function normalizeFormulaScopeValue(value: unknown): unknown { if ("created_at" in normalized && !("registered" in normalized)) { normalized.registered = normalized.created_at; } + if (isRecord(normalized.filament)) { + if (!("weight" in normalized) && "weight" in normalized.filament) { + normalized.weight = normalized.filament.weight; + } + if ((!("price" in normalized) || normalized.price == null) && "price" in normalized.filament) { + normalized.price = normalized.filament.price; + } + } return normalized; } diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index 41069248c..9cf129b58 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -47,6 +47,10 @@ export interface Field extends FieldParameters { export enum DerivedFieldType { number = "number", text = "text", + boolean = "boolean", + date = "date", + datetime = "datetime", + time = "time", } export interface DerivedFieldParameters { @@ -234,9 +238,13 @@ export function usePreviewDerivedField(entity_type: EntityType) { return useMutation< DerivedFieldPreview, unknown, - { expression_json: Record; sample_values: Record } + { + expression_json: Record; + sample_values: Record; + result_type?: DerivedFieldType; + } >({ - mutationFn: async ({ expression_json, sample_values }) => { + mutationFn: async ({ expression_json, sample_values, result_type }) => { const response = await fetch(`${getAPIURL()}/field/derived/${entity_type}/preview`, { method: "POST", headers: { @@ -245,6 +253,7 @@ export function usePreviewDerivedField(entity_type: EntityType) { body: JSON.stringify({ expression_json, sample_values, + result_type, }), }); diff --git a/scripts/json_logic_parity.py b/scripts/json_logic_parity.py deleted file mode 100644 index 0bad6b07f..000000000 --- a/scripts/json_logic_parity.py +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env python3 -"""Run JSON Logic parity fixtures against a selected Python runtime.""" - -from __future__ import annotations - -import argparse -import json -import math -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -TIME_RE = re.compile(r"^\d{2}:\d{2}:\d{2}$") - - -@dataclass -class FixtureResult: - fixture_id: str - status: str - detail: str = "" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--fixtures", - type=Path, - default=Path("tests_integration/tests/fields/json_logic_parity_fixtures.json"), - help="Path to JSON fixtures file.", - ) - parser.add_argument( - "--engine", - choices=["json_logic_py"], - default="json_logic_py", - help="Python evaluator runtime to use.", - ) - return parser.parse_args() - - -def _as_datetime(value: Any) -> datetime: - if isinstance(value, datetime): - dt = value - elif isinstance(value, str): - normalized = value.strip() - if normalized.endswith("Z"): - normalized = f"{normalized[:-1]}+00:00" - dt = datetime.fromisoformat(normalized) - else: - raise ValueError(f"Unsupported datetime input: {value!r}") - - if dt.tzinfo is None: - return dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc) - - -def _date_only(value: Any) -> str: - return _as_datetime(value).date().isoformat() - - -def _time_only(value: Any) -> str: - return _as_datetime(value).time().replace(microsecond=0).isoformat() - - -def _days_between(start: Any, end: Any) -> float: - return (_as_datetime(end) - _as_datetime(start)).total_seconds() / 86400 - - -def _hours_between(start: Any, end: Any) -> float: - return (_as_datetime(end) - _as_datetime(start)).total_seconds() / 3600 - - -def _hue_from_hex(value: Any) -> float: - if not isinstance(value, str): - raise ValueError("hue_from_hex expects a hex color string.") - - normalized = value.strip().lstrip("#") - if len(normalized) == 3: - normalized = "".join(char * 2 for char in normalized) - if len(normalized) != 6: - raise ValueError("hue_from_hex expects 3 or 6 hex digits.") - - red = int(normalized[0:2], 16) / 255 - green = int(normalized[2:4], 16) / 255 - blue = int(normalized[4:6], 16) / 255 - - max_value = max(red, green, blue) - min_value = min(red, green, blue) - delta = max_value - min_value - if delta == 0: - return 0.0 - - if max_value == red: - hue = ((green - blue) / delta) % 6 - elif max_value == green: - hue = (blue - red) / delta + 2 - else: - hue = (red - green) / delta + 4 - - return round((hue * 60) % 360, 3) - - -def _coalesce(*values: Any) -> Any: - for value in values: - if value is not None: - return value - return None - - -def _replace(value: Any, old: Any, new: Any) -> str: - return str(value).replace(str(old), str(new)) - - -def _to_number(value: Any) -> float: - if isinstance(value, bool): - return float(int(value)) - if isinstance(value, (int, float)): - return float(value) - return float(str(value)) - - -def _install_custom_operations(operations: dict[str, Any]) -> None: - # Keep helper/operator names aligned with the planned Formula Extra Fields vocabulary so this - # harness reflects the real target behavior instead of raw library defaults. - operations.update( - { - "abs": abs, - "round": round, - "floor": math.floor, - "ceil": math.ceil, - "coalesce": _coalesce, - "today": lambda: datetime.now(timezone.utc).date().isoformat(), - "date_only": _date_only, - "time_only": _time_only, - "days_between": _days_between, - "hours_between": _hours_between, - "hue_from_hex": _hue_from_hex, - "upper": lambda value: str(value).upper(), - "lower": lambda value: str(value).lower(), - "trim": lambda value: str(value).strip(), - "length": lambda value: len(value), - "replace": _replace, - "timestamp": lambda value: _as_datetime(value).timestamp(), - "to_number": _to_number, - }, - ) - - -def _load_engine(engine_name: str) -> Any: - if engine_name != "json_logic_py": - raise RuntimeError(f"Unsupported engine: {engine_name}") - - try: - from json_logic import jsonLogic, operations # type: ignore[import-not-found] - except ModuleNotFoundError as exc: - raise RuntimeError( - "json-logic-py is not installed. Install candidate runtime first, e.g. " - "`pip install json-logic-py`.", - ) from exc - - _install_custom_operations(operations) - return jsonLogic - - -def _validate_result_type(value: Any, result_type: str) -> None: - if result_type == "number": - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise ValueError("result_type_mismatch:number") - return - if result_type == "text": - if not isinstance(value, str): - raise ValueError("result_type_mismatch:text") - return - if result_type == "boolean": - if not isinstance(value, bool): - raise ValueError("result_type_mismatch:boolean") - return - if result_type == "date": - if not isinstance(value, str) or DATE_RE.match(value) is None: - raise ValueError("result_type_mismatch:date") - return - if result_type == "time": - if not isinstance(value, str) or TIME_RE.match(value) is None: - raise ValueError("result_type_mismatch:time") - return - if result_type == "datetime": - if not isinstance(value, str): - raise ValueError("result_type_mismatch:datetime") - _as_datetime(value) - return - raise ValueError(f"Unsupported result type: {result_type}") - - -def _classify_error(exc: Exception) -> str: - message = str(exc) - if "Unrecognized operation" in message: - return "operator_not_allowed" - if "result_type_mismatch" in message: - return "result_type_mismatch" - if any(token in message for token in ["NoneType", "missing", "float()", "unsupported operand"]): - return "missing_reference" - return "other" - - -def _compare_values(actual: Any, expected: Any) -> bool: - if isinstance(actual, (float, int)) and isinstance(expected, (float, int)): - return math.isclose(float(actual), float(expected), rel_tol=1e-9, abs_tol=1e-9) - return actual == expected - - -def run_fixtures(fixtures_path: Path, engine_name: str) -> list[FixtureResult]: - evaluator = _load_engine(engine_name) - fixtures = json.loads(fixtures_path.read_text(encoding="utf-8")) - results: list[FixtureResult] = [] - - for fixture in fixtures: - fixture_id = fixture["id"] - result_type = fixture["result_type"] - expression_json = fixture["expression_json"] - scope = fixture.get("scope", {}) - expected_error = fixture.get("expect_error") - expected_value = fixture.get("expected") - expected_shape = fixture.get("expected_shape") - - try: - actual = evaluator(expression_json, scope) - _validate_result_type(actual, result_type) - if expected_error: - results.append( - FixtureResult( - fixture_id=fixture_id, - status="fail", - detail=f"expected error `{expected_error}` but evaluation succeeded with `{actual!r}`", - ), - ) - continue - if expected_shape == "yyyy-mm-dd": - if not isinstance(actual, str) or DATE_RE.match(actual) is None: - results.append( - FixtureResult( - fixture_id=fixture_id, - status="fail", - detail=f"expected date shape yyyy-mm-dd but got `{actual!r}`", - ), - ) - continue - elif expected_value is not None and not _compare_values(actual, expected_value): - results.append( - FixtureResult( - fixture_id=fixture_id, - status="fail", - detail=f"expected `{expected_value!r}` but got `{actual!r}`", - ), - ) - continue - results.append(FixtureResult(fixture_id=fixture_id, status="pass")) - except Exception as exc: # noqa: BLE001 - if not expected_error: - results.append(FixtureResult(fixture_id=fixture_id, status="fail", detail=f"unexpected error: {exc}")) - continue - actual_error = _classify_error(exc) - if actual_error != expected_error: - results.append( - FixtureResult( - fixture_id=fixture_id, - status="fail", - detail=f"expected error `{expected_error}` but got `{actual_error}` ({exc})", - ), - ) - continue - results.append(FixtureResult(fixture_id=fixture_id, status="pass")) - - return results - - -def main() -> int: - args = parse_args() - try: - results = run_fixtures(args.fixtures, args.engine) - except RuntimeError as exc: - print(f"ERROR: {exc}") # noqa: T201 - return 2 - - passed = [result for result in results if result.status == "pass"] - failed = [result for result in results if result.status != "pass"] - - print(f"Engine: {args.engine}") # noqa: T201 - print(f"Fixtures: {args.fixtures}") # noqa: T201 - print(f"Passed: {len(passed)} / {len(results)}") # noqa: T201 - if failed: - print("Failures:") # noqa: T201 - for item in failed: - print(f"- {item.fixture_id}: {item.detail}") # noqa: T201 - return 1 - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py index 929fbb885..6d36c30ad 100644 --- a/spoolman/api/v1/field.py +++ b/spoolman/api/v1/field.py @@ -61,16 +61,18 @@ async def get_derived( responses={400: {"model": Message}}, ) async def preview_derived( + db: Annotated[AsyncSession, Depends(get_db_session)], entity_type: Annotated[EntityType, Path(description="Entity type this derived field is for")], body: DerivedFieldPreviewRequest, ) -> DerivedFieldPreviewResponse | JSONResponse: - # The route stays entity-scoped for UI symmetry, but preview validation is intentionally pure: - # it only checks expression syntax/helpers against sample values and does not read entity data. - del entity_type try: + extra_field_keys = {field.key for field in await get_extra_fields(db, entity_type)} return preview_derived_payload( + entity_type=entity_type, expression_json=body.expression_json, sample_values=body.sample_values, + extra_field_keys=extra_field_keys, + result_type=body.result_type, ) except ValueError as exc: return JSONResponse(status_code=400, content=Message(message=str(exc)).dict()) @@ -110,7 +112,8 @@ async def update_derived( "/derived/{entity_type}/{key}", name="Delete derived field", description=( - "Delete a derived field for a specific entity type. Returns the full list of derived fields for the entity type." + "Delete a derived field for a specific entity type. " + "Returns the full list of derived fields for the entity type." ), response_model_exclude_none=True, response_model=list[DerivedFieldDefinition], @@ -194,6 +197,8 @@ async def delete( ) -> list[ExtraField] | JSONResponse: try: await delete_extra_field(db, entity_type, key) + except ValueError as exc: + return JSONResponse(status_code=400, content=Message(message=str(exc)).dict()) except ItemNotFoundError: return JSONResponse( status_code=404, diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 1b595a20b..3dc242d3a 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -371,11 +371,11 @@ async def find( limit=limit, offset=offset, ) - include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived=include_derived) payload: list[Filament] = [] - # List endpoints should only evaluate fields configured for list/table surfaces. + # API exposure is field-level via include_in_api and is not coupled to UI surfaces. derived_fields = ( - await get_derived_fields_for_surface(db, EntityType.filament, "list", api_enabled_only=True) + await get_derived_fields_for_surface(db, EntityType.filament, None, api_enabled_only=True) if include_derived_resolved else [] ) @@ -447,13 +447,12 @@ async def get( ) -> Filament: db_item = await filament.get_by_id(db, filament_id) filament_payload = Filament.from_db(db_item) - include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived=include_derived) if include_derived_resolved: - # Detail endpoints should evaluate show-surface formulas only. derived_fields = await get_derived_fields_for_surface( db, EntityType.filament, - "show", + None, api_enabled_only=True, ) if derived_fields: diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 5f17224a2..6dc83171b 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -315,11 +315,11 @@ async def find( limit=limit, offset=offset, ) - include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived=include_derived) payload: list[Spool] = [] - # List endpoints should only evaluate fields configured for list/table surfaces. + # API exposure is field-level via include_in_api and is not coupled to UI surfaces. derived_fields = ( - await get_derived_fields_for_surface(db, EntityType.spool, "list", api_enabled_only=True) + await get_derived_fields_for_surface(db, EntityType.spool, None, api_enabled_only=True) if include_derived_resolved else [] ) @@ -391,10 +391,9 @@ async def get( ) -> Spool: db_item = await spool.get_by_id(db, spool_id) spool_payload = Spool.from_db(db_item) - include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived=include_derived) if include_derived_resolved: - # Detail endpoints should evaluate show-surface formulas only. - derived_fields = await get_derived_fields_for_surface(db, EntityType.spool, "show", api_enabled_only=True) + derived_fields = await get_derived_fields_for_surface(db, EntityType.spool, None, api_enabled_only=True) if derived_fields: scope = build_formula_scope(spool_payload.model_dump(exclude_none=True)) derived_values = evaluate_derived_fields_for_scope( diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index cbc4ab363..bdfdce0ae 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -148,11 +148,11 @@ async def find( limit=limit, offset=offset, ) - include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived=include_derived) payload: list[Vendor] = [] - # List endpoints should only evaluate fields configured for list/table surfaces. + # API exposure is field-level via include_in_api and is not coupled to UI surfaces. derived_fields = ( - await get_derived_fields_for_surface(db, EntityType.vendor, "list", api_enabled_only=True) + await get_derived_fields_for_surface(db, EntityType.vendor, None, api_enabled_only=True) if include_derived_resolved else [] ) @@ -223,10 +223,9 @@ async def get( ) -> Vendor: db_item = await vendor.get_by_id(db, vendor_id) vendor_payload = Vendor.from_db(db_item) - include_derived_resolved = await resolve_include_derived_in_api(db, include_derived) + include_derived_resolved = await resolve_include_derived_in_api(db, include_derived=include_derived) if include_derived_resolved: - # Detail endpoints should evaluate show-surface formulas only. - derived_fields = await get_derived_fields_for_surface(db, EntityType.vendor, "show", api_enabled_only=True) + derived_fields = await get_derived_fields_for_surface(db, EntityType.vendor, None, api_enabled_only=True) if derived_fields: scope = build_formula_scope(vendor_payload.model_dump(exclude_none=True)) derived_values = evaluate_derived_fields_for_scope( diff --git a/spoolman/derived_fields.py b/spoolman/derived_fields.py index 0d8c396b5..1ef9e0930 100644 --- a/spoolman/derived_fields.py +++ b/spoolman/derived_fields.py @@ -1,5 +1,7 @@ """User-defined derived fields with safe expression evaluation.""" +# ruff: noqa: ANN401, BLE001, C901, PERF203, PLR0911, PLR0912, PLR0915, PLR2004, TRY004 + import colorsys import json import logging @@ -13,7 +15,7 @@ from spoolman.database import setting as db_setting from spoolman.exceptions import ItemNotFoundError -from spoolman.extra_fields import EntityType +from spoolman.extra_fields import EntityType, get_extra_fields from spoolman.settings import parse_setting logger = logging.getLogger(__name__) @@ -24,6 +26,10 @@ class DerivedFieldType(Enum): number = "number" text = "text" + boolean = "boolean" + date = "date" + datetime = "datetime" + time = "time" class DerivedFieldDefinition(BaseModel): @@ -69,6 +75,7 @@ class DerivedFieldPreviewRequest(BaseModel): expression_json: dict[str, Any] = Field(description="Derived expression in JSON Logic format") sample_values: dict[str, Any] = Field(default_factory=dict, description="Sample values keyed by field reference") + result_type: DerivedFieldType | None = Field(default=None, description="Expected result type for the preview") class DerivedFieldPreviewResponse(BaseModel): @@ -206,6 +213,98 @@ def _right(value: Any, count: Any = 1) -> str: "hue_from_hex", } +BUILTIN_REFERENCES: dict[EntityType, set[str]] = { + EntityType.vendor: { + "id", + "registered", + "created_at", + "name", + "comment", + "empty_spool_weight", + "external_id", + "extra", + }, + EntityType.filament: { + "id", + "registered", + "created_at", + "name", + "material", + "price", + "density", + "diameter", + "weight", + "spool_weight", + "article_number", + "comment", + "settings_extruder_temp", + "settings_bed_temp", + "color_hex", + "multi_color_hexes", + "multi_color_direction", + "external_id", + "extra", + "vendor", + "vendor.id", + "vendor.registered", + "vendor.created_at", + "vendor.name", + "vendor.comment", + "vendor.empty_spool_weight", + "vendor.external_id", + "vendor.extra", + }, + EntityType.spool: { + "id", + "weight", + "registered", + "created_at", + "first_used", + "last_used", + "price", + "initial_weight", + "spool_weight", + "remaining_weight", + "used_weight", + "remaining_length", + "used_length", + "location", + "lot_nr", + "comment", + "archived", + "extra", + "filament", + "filament.id", + "filament.registered", + "filament.created_at", + "filament.name", + "filament.material", + "filament.price", + "filament.density", + "filament.diameter", + "filament.weight", + "filament.spool_weight", + "filament.article_number", + "filament.comment", + "filament.settings_extruder_temp", + "filament.settings_bed_temp", + "filament.color_hex", + "filament.multi_color_hexes", + "filament.multi_color_direction", + "filament.external_id", + "filament.extra", + "filament.vendor", + "filament.vendor.id", + "filament.vendor.registered", + "filament.vendor.created_at", + "filament.vendor.name", + "filament.vendor.comment", + "filament.vendor.empty_spool_weight", + "filament.vendor.external_id", + "filament.vendor.extra", + }, +} + def _normalize_json_logic_args(raw_value: Any) -> list[Any]: if isinstance(raw_value, list): @@ -278,11 +377,139 @@ def _validate_json_logic_node(node: Any, references: set[str]) -> None: def validate_derived_expression_json(expression_json: dict[str, Any]) -> list[str]: + """Validate the JSON Logic structure and return referenced field paths.""" references: set[str] = set() _validate_json_logic_node(expression_json, references) return sorted(references) +def _validate_derived_references( + *, + entity_type: EntityType, + references: list[str], + extra_field_keys: set[str], +) -> None: + allowed_references = BUILTIN_REFERENCES[entity_type] + invalid_references: list[str] = [] + for reference in references: + if reference.startswith("extra."): + extra_field_key = reference[len("extra.") :] + if extra_field_key not in extra_field_keys: + invalid_references.append(reference) + continue + if reference not in allowed_references: + invalid_references.append(reference) + if invalid_references: + joined = ", ".join(sorted(set(invalid_references))) + raise ValueError(f"Unknown field reference(s): {joined}.") + + +def _merge_inferred_types(types: list[DerivedFieldType | None]) -> DerivedFieldType | None: + known_types = [type_hint for type_hint in types if type_hint is not None] + if not known_types: + return None + first_type = known_types[0] + if all(type_hint == first_type for type_hint in known_types[1:]): + return first_type + return None + + +def infer_derived_result_type(node: Any) -> DerivedFieldType | None: + """Infer a stable result type when the JSON Logic operator makes it explicit.""" + if isinstance(node, bool): + return DerivedFieldType.boolean + if isinstance(node, (int, float)) and not isinstance(node, bool): + return DerivedFieldType.number + if isinstance(node, str): + return DerivedFieldType.text + if node is None or isinstance(node, list) or not isinstance(node, dict): + return None + + items = list(node.items()) + if len(items) != 1: + return None + operator, raw_args = items[0] + args = _normalize_json_logic_args(raw_args) + + if operator == "var": + return None + if operator == "if": + branch_types = [infer_derived_result_type(args[index]) for index in range(1, len(args), 2)] + if len(args) % 2 == 1: + branch_types.append(infer_derived_result_type(args[-1])) + return _merge_inferred_types(branch_types) + if operator == "coalesce": + return _merge_inferred_types([infer_derived_result_type(arg) for arg in args]) + if operator in {"==", "!=", "<", "<=", ">", ">=", "!", "and", "or"}: + return DerivedFieldType.boolean + if operator in { + "+", + "-", + "*", + "/", + "%", + "abs", + "min", + "max", + "round", + "floor", + "ceil", + "year", + "month", + "day", + "hour", + "minute", + "second", + "timestamp", + "days_between", + "hours_between", + "hue_from_hex", + "length", + }: + return DerivedFieldType.number + if operator in {"today", "date_only"}: + return DerivedFieldType.date + if operator == "time_only": + return DerivedFieldType.time + if operator in {"cat", "replace", "trim", "upper", "lower", "left", "right"}: + return DerivedFieldType.text + return None + + +def _matches_derived_result_type(value: Any, result_type: DerivedFieldType) -> bool: + if result_type == DerivedFieldType.number: + return isinstance(value, (int, float)) and not isinstance(value, bool) + if result_type == DerivedFieldType.text: + return isinstance(value, str) + if result_type == DerivedFieldType.boolean: + return isinstance(value, bool) + if result_type == DerivedFieldType.date: + if not isinstance(value, str) or "T" in value: + return False + try: + date.fromisoformat(value) + except ValueError: + return False + return True + if result_type == DerivedFieldType.datetime: + if not isinstance(value, str) or "T" not in value: + return False + try: + _as_datetime(value) + except ValueError: + return False + return True + if result_type == DerivedFieldType.time: + if not isinstance(value, str): + return False + try: + time.fromisoformat(value) + except ValueError: + return False + return True + return False + + def _evaluate_json_logic(node: Any, scope: dict[str, Any]) -> Any: if isinstance(node, (str, int, float, bool)) or node is None: return node @@ -420,25 +647,57 @@ def _evaluate_json_logic(node: Any, scope: dict[str, Any]) -> Any: def preview_derived_expression_json( expression_json: dict[str, Any], sample_values: dict[str, Any], + *, + result_type: DerivedFieldType | None = None, ) -> DerivedFieldPreviewResponse: + """Evaluate a preview payload and optionally enforce its configured result type.""" references = validate_derived_expression_json(expression_json) try: result = _evaluate_json_logic(expression_json, sample_values) except Exception as exc: raise ValueError(str(exc)) from exc + if result_type is not None and not _matches_derived_result_type(result, result_type): + raise ValueError(f"Preview result does not match the configured result type '{result_type.value}'.") return DerivedFieldPreviewResponse(result=_normalize_preview_result(result), references=references) def preview_derived_payload( *, + entity_type: EntityType, expression_json: dict[str, Any], sample_values: dict[str, Any], + extra_field_keys: set[str], + result_type: DerivedFieldType | None = None, ) -> DerivedFieldPreviewResponse: - return preview_derived_expression_json(expression_json, sample_values) + """Validate references for the target entity and evaluate the preview.""" + references = validate_derived_expression_json(expression_json) + _validate_derived_references( + entity_type=entity_type, + references=references, + extra_field_keys=extra_field_keys, + ) + return preview_derived_expression_json(expression_json, sample_values, result_type=result_type) -def _validate_expression_payload(expression_json: dict[str, Any]) -> None: - validate_derived_expression_json(expression_json) +async def _validate_expression_payload( + db: AsyncSession, + entity_type: EntityType, + expression_json: dict[str, Any], + result_type: DerivedFieldType, +) -> None: + references = validate_derived_expression_json(expression_json) + extra_field_keys = {field.key for field in await get_extra_fields(db, entity_type)} + _validate_derived_references( + entity_type=entity_type, + references=references, + extra_field_keys=extra_field_keys, + ) + inferred_type = infer_derived_result_type(expression_json) + if inferred_type is not None and inferred_type != result_type: + raise ValueError( + "Expression result type " + f"'{inferred_type.value}' does not match configured result type '{result_type.value}'." + ) def _parse_extra_field_value(value: Any) -> Any: @@ -471,6 +730,12 @@ def _normalize_formula_scope(value: Any) -> Any: normalized["created_at"] = normalized["registered"] if "created_at" in normalized and "registered" not in normalized: normalized["registered"] = normalized["created_at"] + filament_payload = normalized.get("filament") + if isinstance(filament_payload, dict): + if "weight" not in normalized and "weight" in filament_payload: + normalized["weight"] = filament_payload["weight"] + if normalized.get("price") is None and "price" in filament_payload: + normalized["price"] = filament_payload["price"] return normalized if isinstance(value, list): return [_normalize_formula_scope(item) for item in value] @@ -568,7 +833,7 @@ async def get_derived_fields_for_surface( return filtered_fields -async def resolve_include_derived_in_api(db: AsyncSession, include_derived: bool | None) -> bool: +async def resolve_include_derived_in_api(db: AsyncSession, *, include_derived: bool | None) -> bool: """Resolve per-request include_derived with a settings-level default.""" if include_derived is not None: return include_derived @@ -597,7 +862,12 @@ async def add_or_update_derived_field( db: AsyncSession, entity_type: EntityType, derived_field: DerivedFieldDefinition ) -> None: """Create or update a derived field.""" - _validate_expression_payload(derived_field.expression_json) + await _validate_expression_payload( + db, + entity_type, + derived_field.expression_json, + derived_field.result_type, + ) existing = await get_derived_fields(db, entity_type) next_fields = [field for field in existing if field.key != derived_field.key] diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py index 2be157e3d..59a0e257c 100644 --- a/spoolman/extra_fields.py +++ b/spoolman/extra_fields.py @@ -13,6 +13,7 @@ from spoolman.database import spool as db_spool from spoolman.database import vendor as db_vendor from spoolman.exceptions import ItemNotFoundError +from spoolman.formula_references import get_extra_field_references from spoolman.settings import parse_setting logger = logging.getLogger(__name__) @@ -205,7 +206,7 @@ async def add_or_update_extra_field(db: AsyncSession, entity_type: EntityType, e logger.info("Added/updated extra field %s for entity type %s.", extra_field.key, entity_type.name) -async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str) -> None: +async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str) -> None: # noqa: C901 """Delete an extra field for a specific entity type.""" extra_fields = await get_extra_fields(db, entity_type) @@ -213,6 +214,30 @@ async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str if not any(field.key == key for field in extra_fields): raise ItemNotFoundError(f"Extra field with key {key} does not exist.") + derived_setting_def = parse_setting(f"derived_fields_{entity_type.name}") + try: + derived_setting = await db_setting.get(db, derived_setting_def) + derived_setting_value = derived_setting.value + except ItemNotFoundError: + derived_setting_value = derived_setting_def.default + derived_setting_array = json.loads(derived_setting_value) + dependent_derived_fields: list[str] = [] + if isinstance(derived_setting_array, list): + for raw_field in derived_setting_array: + if not isinstance(raw_field, dict): + continue + expression_json = raw_field.get("expression_json") + if not isinstance(expression_json, dict): + continue + if key not in get_extra_field_references(expression_json): + continue + dependent_derived_fields.append( + f"{raw_field.get('name', raw_field.get('key', 'unknown'))} ({raw_field.get('key', 'unknown')})" + ) + if dependent_derived_fields: + dependencies = ", ".join(dependent_derived_fields) + raise ValueError(f"Cannot delete extra field {key}; formula fields depend on it: {dependencies}.") + extra_fields = [field for field in extra_fields if field.key != key] setting_def = parse_setting(f"extra_fields_{entity_type.name}") diff --git a/spoolman/formula_references.py b/spoolman/formula_references.py new file mode 100644 index 000000000..873a3d84f --- /dev/null +++ b/spoolman/formula_references.py @@ -0,0 +1,51 @@ +"""Helpers for extracting JSON Logic field references.""" + + +def _normalize_json_logic_args(raw_value: object) -> list[object]: + if isinstance(raw_value, list): + return raw_value + return [raw_value] + + +def _collect_json_logic_references(node: object, references: set[str]) -> None: + if isinstance(node, (str, int, float, bool)) or node is None: + return + if isinstance(node, list): + for value in node: + _collect_json_logic_references(value, references) + return + if not isinstance(node, dict) or len(node) != 1: + return + + operator, raw_args = next(iter(node.items())) + args = _normalize_json_logic_args(raw_args) + if operator == "var": + if not args: + return + reference = args[0] + if isinstance(reference, str) and reference != "": + references.add(reference) + if len(args) > 1: + _collect_json_logic_references(args[1], references) + return + + for arg in args: + _collect_json_logic_references(arg, references) + + +def collect_json_logic_references(expression_json: dict[str, object]) -> list[str]: + """Collect unique `var` references from a JSON Logic expression.""" + references: set[str] = set() + _collect_json_logic_references(expression_json, references) + return sorted(references) + + +def get_extra_field_references(expression_json: dict[str, object]) -> list[str]: + """Collect `extra.` references from a JSON Logic expression.""" + return sorted( + { + reference[len("extra.") :] + for reference in collect_json_logic_references(expression_json) + if reference.startswith("extra.") and reference[len("extra.") :] != "" + } + ) diff --git a/tests_integration/tests/fields/json_logic_parity_fixtures.json b/tests_integration/tests/fields/json_logic_parity_fixtures.json deleted file mode 100644 index 940d54b45..000000000 --- a/tests_integration/tests/fields/json_logic_parity_fixtures.json +++ /dev/null @@ -1,142 +0,0 @@ -[ - { - "id": "n01_add", - "result_type": "number", - "expression_json": { "+": [1, 2, 3] }, - "scope": {}, - "expected": 6 - }, - { - "id": "n02_var_subtract", - "result_type": "number", - "expression_json": { "-": [{ "var": "weight" }, { "var": "remaining_weight" }] }, - "scope": { "weight": 1000, "remaining_weight": 225 }, - "expected": 775 - }, - { - "id": "n03_round_divide", - "result_type": "number", - "expression_json": { "round": [{ "/": [{ "var": "remaining_weight" }, 1000] }] }, - "scope": { "remaining_weight": 225 }, - "expected": 0 - }, - { - "id": "n04_abs_negative", - "result_type": "number", - "expression_json": { "abs": [-32] }, - "scope": {}, - "expected": 32 - }, - { - "id": "b01_and_true", - "result_type": "boolean", - "expression_json": { "and": [{ ">": [{ "var": "remaining_weight" }, 0] }, { "==": [{ "var": "archived" }, false] }] }, - "scope": { "remaining_weight": 10, "archived": false }, - "expected": true - }, - { - "id": "b02_or_false", - "result_type": "boolean", - "expression_json": { "or": [{ "==": [{ "var": "material" }, "PLA" ] }, { "==": [{ "var": "material" }, "PETG"] }] }, - "scope": { "material": "ABS" }, - "expected": false - }, - { - "id": "b03_if_branch", - "result_type": "boolean", - "expression_json": { "if": [{ ">=": [{ "var": "used_weight" }, 900] }, true, false] }, - "scope": { "used_weight": 910 }, - "expected": true - }, - { - "id": "t01_concat", - "result_type": "text", - "expression_json": { "cat": ["Lot ", { "var": "lot_nr" }] }, - "scope": { "lot_nr": "A123" }, - "expected": "Lot A123" - }, - { - "id": "t02_upper", - "result_type": "text", - "expression_json": { "upper": [{ "var": "material" }] }, - "scope": { "material": "pla" }, - "expected": "PLA" - }, - { - "id": "t03_coalesce", - "result_type": "text", - "expression_json": { "coalesce": [{ "var": "extra.short_name" }, { "var": "name" }, "Unknown"] }, - "scope": { "name": "Basic PLA" }, - "expected": "Basic PLA" - }, - { - "id": "d01_today", - "result_type": "date", - "expression_json": { "today": [] }, - "scope": {}, - "expected_shape": "yyyy-mm-dd" - }, - { - "id": "d02_date_only", - "result_type": "date", - "expression_json": { "date_only": [{ "var": "created_at" }] }, - "scope": { "created_at": "2026-02-28T10:15:00Z" }, - "expected": "2026-02-28" - }, - { - "id": "dt01_identity", - "result_type": "datetime", - "expression_json": { "var": "last_used" }, - "scope": { "last_used": "2026-03-01T18:42:10Z" }, - "expected": "2026-03-01T18:42:10Z" - }, - { - "id": "tm01_time_only", - "result_type": "time", - "expression_json": { "time_only": [{ "var": "last_used" }] }, - "scope": { "last_used": "2026-03-01T18:42:10Z" }, - "expected": "18:42:10" - }, - { - "id": "n05_days_between", - "result_type": "number", - "expression_json": { "days_between": [{ "var": "first_used" }, { "var": "last_used" }] }, - "scope": { "first_used": "2026-03-01T00:00:00Z", "last_used": "2026-03-04T12:00:00Z" }, - "expected": 3.5 - }, - { - "id": "n06_hours_between", - "result_type": "number", - "expression_json": { "hours_between": [{ "var": "first_used" }, { "var": "last_used" }] }, - "scope": { "first_used": "2026-03-01T00:00:00Z", "last_used": "2026-03-01T18:00:00Z" }, - "expected": 18 - }, - { - "id": "n07_hue_from_hex", - "result_type": "number", - "expression_json": { "hue_from_hex": ["#FF0000"] }, - "scope": {}, - "expected": 0 - }, - { - "id": "inv01_unknown_operator", - "result_type": "number", - "expression_json": { "sqrt": [9] }, - "scope": {}, - "expect_error": "operator_not_allowed" - }, - { - "id": "inv02_missing_reference", - "result_type": "number", - "expression_json": { "+": [{ "var": "does_not_exist" }, 1] }, - "scope": {}, - "expect_error": "missing_reference" - }, - { - "id": "inv03_type_mismatch", - "result_type": "number", - "expression_json": { "cat": ["a", "b"] }, - "scope": {}, - "expect_error": "result_type_mismatch" - } -] diff --git a/tests_integration/tests/fields/test_derived.py b/tests_integration/tests/fields/test_derived.py index b42f8f54a..975cf08c0 100644 --- a/tests_integration/tests/fields/test_derived.py +++ b/tests_integration/tests/fields/test_derived.py @@ -58,3 +58,56 @@ def test_preview_derived_json_logic_invalid_operator(): ) assert_httpx_code(result, 400) assert "not allowed" in result.json()["message"] + + +def test_preview_derived_json_logic_unknown_reference(): + """Preview should reject references that are not valid for the scoped entity.""" + result = httpx.post( + f"{URL}/api/v1/field/derived/spool/preview", + json={ + "expression_json": {"+": [{"var": "weight_typo"}, 1]}, + "sample_values": {"weight_typo": 1}, + "result_type": "number", + }, + ) + assert_httpx_code(result, 400) + assert "Unknown field reference" in result.json()["message"] + + +def test_delete_extra_field_referenced_by_formula_is_blocked(): + """Custom fields referenced by formulas must be removed from formulas before deletion.""" + extra_key = "delete_guard_source" + formula_key = "delete_guard_formula" + + create_extra_result = httpx.post( + f"{URL}/api/v1/field/spool/{extra_key}", + json={ + "name": "Delete Guard Source", + "order": 1, + "field_type": "text", + }, + ) + assert_httpx_success(create_extra_result) + + create_formula_result = httpx.post( + f"{URL}/api/v1/field/derived/spool/{formula_key}", + json={ + "name": "Delete Guard Formula", + "description": "Created by integration test", + "result_type": "text", + "expression_json": {"cat": ["Value: ", {"var": f"extra.{extra_key}"}]}, + "surfaces": ["show"], + "allow_list_column_toggle": False, + }, + ) + assert_httpx_success(create_formula_result) + + try: + delete_extra_result = httpx.delete(f"{URL}/api/v1/field/spool/{extra_key}") + assert_httpx_code(delete_extra_result, 400) + assert formula_key in delete_extra_result.json()["message"] + finally: + delete_formula_result = httpx.delete(f"{URL}/api/v1/field/derived/spool/{formula_key}") + assert_httpx_success(delete_formula_result) + delete_extra_result = httpx.delete(f"{URL}/api/v1/field/spool/{extra_key}") + assert_httpx_success(delete_extra_result) diff --git a/tests_integration/tests/fields/test_derived_api.py b/tests_integration/tests/fields/test_derived_api.py index fa507c45c..b1f28bc34 100644 --- a/tests_integration/tests/fields/test_derived_api.py +++ b/tests_integration/tests/fields/test_derived_api.py @@ -8,7 +8,7 @@ from ..conftest import URL, assert_httpx_success -def _set_api_include_derived(enabled: bool | None) -> None: +def _set_api_include_derived(*, enabled: bool | None) -> None: if enabled is None: result = httpx.post(f"{URL}/api/v1/setting/api_include_derived_fields", json="") else: @@ -19,7 +19,7 @@ def _set_api_include_derived(enabled: bool | None) -> None: assert_httpx_success(result) -def _create_spool_formula_field(key: str, *, include_in_api: bool) -> None: +def _create_spool_formula_field(key: str, *, include_in_api: bool, surfaces: list[str] | None = None) -> None: create_result = httpx.post( f"{URL}/api/v1/field/derived/spool/{key}", json={ @@ -27,7 +27,7 @@ def _create_spool_formula_field(key: str, *, include_in_api: bool) -> None: "description": "Created by integration test", "result_type": "number", "expression_json": {"+": [{"var": "used_weight"}, {"var": "remaining_weight"}]}, - "surfaces": ["show", "list"], + "surfaces": surfaces or ["show", "list"], "allow_list_column_toggle": False, "include_in_api": include_in_api, }, @@ -58,7 +58,7 @@ def test_spool_api_include_derived_toggle(random_filament: dict[str, Any]): spool = spool_create.json() try: - _set_api_include_derived(None) + _set_api_include_derived(enabled=None) default_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}") assert_httpx_success(default_response) @@ -70,7 +70,7 @@ def test_spool_api_include_derived_toggle(random_filament: dict[str, Any]): assert explicit_enabled_payload["derived"][key] == pytest.approx(1000) assert hidden_key not in explicit_enabled_payload["derived"] - _set_api_include_derived(True) + _set_api_include_derived(enabled=True) default_enabled_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}") assert_httpx_success(default_enabled_response) @@ -95,4 +95,38 @@ def test_spool_api_include_derived_toggle(random_filament: dict[str, Any]): httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() _delete_spool_formula_field(hidden_key) _delete_spool_formula_field(key) - _set_api_include_derived(None) + _set_api_include_derived(enabled=None) + + +def test_spool_api_include_derived_is_independent_of_ui_surfaces(random_filament: dict[str, Any]): + """API exposure should use include_in_api even when the formula is template-only.""" + key = "api_template_only" + _create_spool_formula_field(key, include_in_api=True, surfaces=["template"]) + + spool_create = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "remaining_weight": 800, + }, + ) + assert_httpx_success(spool_create) + spool = spool_create.json() + + try: + explicit_enabled_response = httpx.get(f"{URL}/api/v1/spool/{spool['id']}", params={"include_derived": "true"}) + assert_httpx_success(explicit_enabled_response) + explicit_enabled_payload = explicit_enabled_response.json() + assert explicit_enabled_payload["derived"][key] == pytest.approx(1000) + + list_enabled_response = httpx.get( + f"{URL}/api/v1/spool", + params={"filament.id": str(random_filament["id"]), "include_derived": "true"}, + ) + assert_httpx_success(list_enabled_response) + list_payload = list_enabled_response.json() + matching_spool = next(item for item in list_payload if item["id"] == spool["id"]) + assert matching_spool["derived"][key] == pytest.approx(1000) + finally: + httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() + _delete_spool_formula_field(key) From 4c7ae0ee5109415ef65af8bf1fba9e432360c169 Mon Sep 17 00:00:00 2001 From: akira69 Date: Sun, 29 Mar 2026 16:04:41 -0500 Subject: [PATCH 5/6] feat(settings): prepare json-first formula fields base --- client/public/locales/en/common.json | 57 +- client/src/pages/help/index.tsx | 104 +- .../pages/settings/extraFieldsSettings.tsx | 151 +- .../pages/settings/formulaFieldsSettings.tsx | 2077 ++++++++++++----- client/src/utils/formulaFields.ts | 11 +- 5 files changed, 1685 insertions(+), 715 deletions(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index f4a3aa318..88e93d09c 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -365,6 +365,7 @@ "referenced_in_none": "None", "referenced_in_count": "{{count}} formula field", "referenced_in_count_other": "{{count}} formula fields", + "delete_dependency_tooltip": "Clear formula references before deleting this field.", "delete_dependency_warning_intro": "Deleting this custom field will make dependent fields inoperable:", "delete_dependency_warning_formula": "Formula extra fields: {{dependencies}}", "delete_dependency_warning_footer": "These entries remain saved, but behavior depending on this field will fail until references are updated." @@ -373,11 +374,11 @@ "help_links": { "formula": "Help: Formula Extra Fields", "formula_json": "Help: JSON Logic", - "formula_tokens": "Help: Token Groups" + "formula_tokens": "Help: Writing Formula JSON" }, "available_functions": { "label": "Token categories:", - "value": "Operators, helper functions, and field references are grouped in the editor." + "value": "Searchable grouped references, operators, and helpers are available beside the raw JSON editor." }, "surfaces": { "show": "Show Pages", @@ -391,8 +392,8 @@ "header": "Formula Extra Fields", "intro": "Formula extra fields are read-only derived values computed from expressions that reference existing fields.", "evaluation_model_help": "Formula values are computed when records are loaded and are not stored as database columns. Dynamic helpers like today() refresh when data is reloaded.", - "description": "

Formula fields let you define your own calculated values for this entity.

Use references like {weight} or {created_at}, then combine them with math and helper functions. Formula fields are read-only and can be shown in list or show views.

This is where you can build calculations such as date parts, intervals, or specialized helpers like hue_from_hex(...).

", - "tooltip": "Formula extra fields are user-defined calculated values. Use field references with the available formula functions to build read-only values for the selected display areas.", + "description": "

Formula fields let you define calculated values for this entity.

Write JSON Logic in the editor, then use searchable references, operators, and helper examples as lookup aids while composing your expression.

Formula fields are read-only and can be shown in list, show, template, or API surfaces.

", + "tooltip": "Formula extra fields are user-defined calculated values. Write JSON Logic in the editor and use the references and helper/operator examples below as aids.", "empty": "No formula fields are currently defined for this entity.", "columns": { "key": "Key", @@ -419,7 +420,7 @@ "name": "Human-friendly display label for this field in the UI.", "display_in": "Choose where this calculated field appears: in show/edit pages, tables, templates, or API responses.", "include_in_api": "When enabled, include this field in API responses under the derived object.", - "expression_json": "JSON Logic expression that computes this field. Combine operators, helper functions, and field references below.", + "expression_json": "JSON Logic expression that computes this field. Use operators, helper functions, and field references from the reference area below as lookup aids.", "sample_values": "Test data object to preview results. Provide sample values for all references used in your expression." }, "expression_json_copy_tooltip": "Copy expression JSON to clipboard", @@ -429,7 +430,7 @@ "sample_values_detected_references": "Detected references:", "sample_values_detected_references_empty": "No references detected yet", "sample_values_reference_invalid": "Variable undefined or incorrectly defined in Sample Values JSON.", - "expression_json_help": "Enter a JSON Logic expression. Type manually or use the operator/helper/reference tokens below to build it step-by-step.", + "expression_json_help": "Enter a JSON Logic expression. Type manually and use the grouped references, operators, and helpers below as lookup or copy aids.", "expression_json_example": "Example: {\"-\": [{\"var\": \"weight\"}, {\"var\": \"remaining_weight\"}]}", "expression_json_required": "Expression JSON (JSON Logic) is required.", "expression_json_invalid": "Expression JSON must be valid JSON format (an object like {...}).", @@ -443,7 +444,7 @@ "helpers": "Helpers" }, "token_sections": { - "operators": "Operators", + "operators": "Reference Aids", "helper_functions": "Helper Functions" }, "token_categories": { @@ -459,27 +460,39 @@ }, "reference_picker": { "label": "Field References", - "placeholder": "Pick a field to insert as a reference", - "help": "Click field references to insert them directly. Helper functions require you to select compatible references first. Both built-in fields and custom extra fields are available." + "placeholder": "Pick a field reference", + "search_placeholder": "Search fields or paths", + "help": "Search by short field name or full path, then copy the exact reference path from grouped sections while writing JSON manually.", + "helper_compatible": "Compatible Inputs", + "no_helper_selected": "No token selected", + "no_helper_selected_help": "Choose a token above to see compatible field inputs across all reference groups.", + "no_compatible_fields": "No compatible fields are available for the selected token.", + "current_scope": "Current", + "related_scope": "Related", + "no_results": "No field references match this search.", + "copy_reference_tooltip": "Copy reference path", + "reference_copied": "Reference copied to clipboard." }, "json_builder": { - "operators_title": "Insert Tokens", - "click_to_insert_help": "Click field references to insert them immediately. For helpers, select compatible references first, or use the 'Helper only' button to insert with placeholder arguments.", - "pending_helper": "Pending reference for helper {{helper}} ({{selected}}/{{total}})", - "pending_helper_prefix": "Pending reference for helper", + "operators_title": "Available JSON References, Operators, and Helpers", + "click_to_insert_help": "Use this panel as a reference while writing JSON Logic manually. Clicking a reference copies its path, and clicking an operator or helper copies an example snippet.", + "copy_hint": "Click any item to copy its path or JSON example.", + "pending_helper": "Pending input for token {{helper}} ({{selected}}/{{total}})", + "pending_helper_prefix": "Pending input for token", "pending_helper_count": "({{selected}}/{{total}})", "if_step_condition_operator": "Next: select IF condition operator", "if_step_condition_left": "Next: select IF condition left operand", "if_step_condition_right": "Next: select IF condition right operand", "if_step_then": "Next: select IF Then value", "if_step_else": "Next: select IF Else value", - "helper_unavailable_reason": "Helper {{helper}} has no compatible references for this entity yet.", - "helper_incompatible_reason": "Helper {{helper}} is incompatible with the currently selected pending reference type.", - "reference_incompatible_reason": "Selected reference is incompatible with helper {{helper}}.", + "nested_if_raw_json": "Nested IF expressions are not supported in guided mode yet. Use raw JSON for nested IF.", + "helper_unavailable_reason": "Token {{helper}} has no compatible field inputs for this entity yet.", + "helper_incompatible_reason": "Token {{helper}} is incompatible with the currently selected pending input type.", + "reference_incompatible_reason": "Selected field is incompatible with token {{helper}}.", "show_operators": "Show operators", "hide_operators": "Hide operators", - "show_tokens": "Show tokens", - "hide_tokens": "Hide tokens", + "show_tokens": "Show reference aids", + "hide_tokens": "Hide reference aids", "operator_compact": { "logical_top": "Logical", "logical_bottom": "Conditional", @@ -489,9 +502,13 @@ "format": "Format JSON", "format_tooltip": "Normalizes and pretty-prints the current JSON in the editor.", "formatted": "Expression JSON formatted.", + "copy_operator_tooltip": "Copy operator example", + "copy_helper_tooltip": "Copy helper example", + "operator_copied": "Operator example copied to clipboard.", + "helper_copied": "Helper example copied to clipboard.", "insert_without_reference_tooltip": "Insert helper with placeholder inputs and clear pending selection.", - "cancel_pending_tooltip": "Cancel pending helper selection.", - "helper_only": "Helper only" + "cancel_pending_tooltip": "Cancel pending token input.", + "helper_only": "Insert placeholders" }, "delete_confirm": "Delete formula field {{name}}?", "modal": { diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx index e09f82f85..d5f689e17 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -28,12 +28,24 @@ const BUILT_IN_FIELD_DEFINITIONS: Record {
{renderLevel3Heading("Extra Fields", 24)} - Extra fields let you store additional data directly and define user-maintained derived values across - entities. + Extra fields let you store additional data directly and define user-maintained derived values across entities. - Configure definitions in{" "} - Settings → Extra Fields → Spools,{" "} + Configure definitions in Settings → Extra Fields → Spools,{" "} Filaments, and{" "} Manufacturers. @@ -280,12 +298,12 @@ export const Help = () => { Supported types include text, integer, integer_range,{" "} - float, float_range, datetime,{" "} - boolean, and choice. + float, float_range, datetime, boolean, + and choice. - In custom extra fields, key and type are immutable after creation. For choice fields, existing choices and - the multi-choice mode are also immutable. Deleting a field removes its data from all records. + In custom extra fields, key and type are immutable after creation. For choice fields, existing choices and the + multi-choice mode are also immutable. Deleting a field removes its data from all records. Keys should stay stable because APIs and integrations use them as identifiers. Default values apply only to @@ -306,9 +324,9 @@ export const Help = () => { Configure them in Settings → Extra Fields for Spools, Filaments, or - Manufacturers. In Formula Extra Fields, click +, build your JSON - expression, then validate with Sample Values (JSON) and Refresh before - saving. + Manufacturers. In Formula Extra Fields, click +, write your JSON + expression in the editor, then validate with Sample Values (JSON) and{" "} + Refresh before saving. In each formula field editor, Display In uses visible checkboxes for{" "} @@ -320,9 +338,8 @@ export const Help = () => { Template/API integrations use the key path {`derived.`}. - Entity responses include only field-level API opt-ins under a derived object when - derived output is requested by the endpoint. Each field key is exposed as{" "} - {`derived.`}. + Entity responses include only field-level API opt-ins under a derived object when derived + output is requested by the endpoint. Each field key is exposed as {`derived.`}. Formula values are computed when records are loaded and are not stored as dedicated database columns. Dynamic @@ -334,22 +351,21 @@ export const Help = () => { JSON Logic - Token groups are clickable inserts that speed up authoring and reduce JSON syntax mistakes. + The editor is JSON-first. The reference area below the editor groups available field references, operators, + and helpers so you can search, inspect examples, and copy exact values while composing your expression. - Field References insert JSON Logic variable objects. For example,{" "} - {`{weight}`} inserts {`{"var":"weight"}`} and{" "} - {`{extra.purchase_date}`} inserts {`{"var":"extra.purchase_date"}`}. + Field References expose the exact variable paths available to the selected entity. For + example, {`weight`} maps to {`{"var":"weight"}`} and{" "} + {`extra.purchase_date`} maps to {`{"var":"extra.purchase_date"}`}. - Operators insert operator templates, and Helper Functions insert - helper templates that can be completed with compatible field references. + Operators and Helper Functions show valid JSON examples you can copy + into the editor, then adjust as needed for your formula. - Helper insertion is staged: click a helper first, then click the required compatible references. While a - helper is pending, incompatible helper/reference tokens are visible but dimmed. Use X to - cancel pending helper selection, or Helper only to insert that helper with placeholder - inputs. + Use the search box to filter references by short name or full path. Clicking an item copies its reference path + or JSON example so you can paste it into the raw editor without retyping. Formula-to-formula references are not supported. Build nested JSON Logic in a single formula instead of @@ -357,13 +373,12 @@ export const Help = () => { {`derived.`}. - On wider layouts, operators are shown in a right-side panel next to the JSON editor and can be collapsed or - expanded. On narrow layouts, operators are hidden from the panel and can still be entered directly in JSON. + The grouped reference area can be collapsed if you want to focus only on the editor and preview panels.
- Token Groups + Available JSON References, Operators, and Helpers
{ Concrete Examples - Variables come from available field references for the selected entity, including built-in fields - (for example {`{created_at}`}) and custom fields - (for example {`{extra.purchase_date}`}). + Variables come from available field references for the selected entity, including built-in fields (for + example {`created_at`}) and custom fields (for example{" "} + {`extra.purchase_date`}).
{ Sample Values (JSON) must be a valid JSON object used only for preview/testing. Use plain keys without braces, and match keys to your {`{"var":"..."}`} references. Example:{" "} - {`{"weight": 1000, "remaining_weight": 225, "created_at": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}`}. + {`{"weight": 1000, "remaining_weight": 225, "created_at": "2026-02-28T10:15:00Z", "color_hex": "#FF00FF"}`} + . The editor also shows detected references from your expression and auto-scaffolds missing sample-value keys @@ -536,11 +554,17 @@ export const Help = () => { - Choose where each formula appears: Show Pages (record details),{" "} - Tables (table/list pages), and Template Selections{" "} - (label/title/filename templates). + Choose where each formula appears: Show Pages (record details), Tables{" "} + (table/list pages), and Template Selections (label/title/filename templates). -
    +
    • Tables controls whether the formula appears in list/table pages at all.
    • diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index 8f5202d36..e613005fb 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined } from "@ant-design/icons"; +import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; import { Button, @@ -61,6 +61,11 @@ interface FieldHolder { is_new: boolean; } +type FormulaFieldEditRequest = { + key: string; + nonce: number; +}; + const canEditField = (dataIndex: string, isNew: boolean) => { if (isNew) { return true; @@ -70,15 +75,14 @@ const canEditField = (dataIndex: string, isNew: boolean) => { const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps }: EditableCellProps) => { const t = useTranslate(); + const mergedCellStyle = { + ...(restProps.style || {}), + wordBreak: "break-word" as const, + }; if (!editing || !canEditField(dataIndex, record.is_new)) { return ( -
); @@ -293,7 +297,11 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps ) : null; - return ; + return ( + + ); }; export function ExtraFieldsSettings() { @@ -307,6 +315,7 @@ export function ExtraFieldsSettings() { const deleteField = useDeleteField(entityType as EntityType); const [isSubmitting, setIsSubmitting] = useState(false); const [newField, setNewField] = useState(null); + const [formulaEditRequest, setFormulaEditRequest] = useState(null); const [messageApi, contextHolder] = message.useMessage(); @@ -475,49 +484,67 @@ export function ExtraFieldsSettings() { }); return dependencies; }, [derivedFields.data]); + const renderCodeValue = (value: string | number | boolean | null | undefined) => { + if (value == null || value === "") { + return -; + } + return ( + + {String(value)} + + ); + }; const columns: ColumnType[] = [ { - title: t("settings.extra_fields.params.key"), + title: {t("settings.extra_fields.params.key")}, dataIndex: ["field", "key"], key: "key", - width: "10%", + width: 132, + fixed: "left", + render: (value: string) => ( + + {value} + + ), }, { - title: t("settings.extra_fields.params.order"), + title: {t("settings.extra_fields.params.order")}, dataIndex: ["field", "order"], key: "order", - width: "3%", + width: 72, }, { - title: t("settings.extra_fields.params.name"), + title: {t("settings.extra_fields.params.name")}, dataIndex: ["field", "name"], + width: 160, }, { - title: t("settings.extra_fields.params.field_type"), + title: {t("settings.extra_fields.params.field_type")}, dataIndex: ["field", "field_type"], render(value) { - return t(`settings.extra_fields.field_type.${value}`); + return renderCodeValue(value as string); }, - width: "15%", + width: 108, }, { - title: t("settings.extra_fields.params.unit"), + title: {t("settings.extra_fields.params.unit")}, dataIndex: ["field", "unit"], - width: "6%", + width: 72, + render: (value) => renderCodeValue(value as string | undefined), }, { - title: t("settings.extra_fields.params.default_value"), + title: {t("settings.extra_fields.params.default_value")}, dataIndex: ["field", "default_value"], render(value, record) { const val = JSON.parse(value || "null"); if (typeof val === "boolean") { - return val ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false"); + return renderCodeValue(val); } else if (typeof val === "string" && record.field.field_type === FieldType.datetime) { - return dayjs(val).format(dateTimeFormat); + return renderCodeValue(dayjs(val).format(dateTimeFormat)); } else if (typeof val === "number" || typeof val === "string") { - return val; + return renderCodeValue(val); } else if (Array.isArray(val) && record.field.field_type === FieldType.choice) { - return val.join(", "); + return renderCodeValue(val.join(", ")); } else if ( Array.isArray(val) && (record.field.field_type === FieldType.integer_range || record.field.field_type === FieldType.float_range) @@ -527,63 +554,69 @@ export function ExtraFieldsSettings() { if (lower === "" && upper === "") { return null; } - return `${lower} \u2013 ${upper}`; + return renderCodeValue(`${lower} - ${upper}`); } else { return null; } }, - width: "15%", + width: 132, }, { - title: t("settings.extra_fields.params.choices"), + title: {t("settings.extra_fields.params.choices")}, dataIndex: ["field", "choices"], render(value, record) { if (record.field.field_type === FieldType.choice && Array.isArray(value)) { - return value.join(", "); + return renderCodeValue(value.join(", ")); } else { return null; } }, - width: "15%", + width: 148, }, { - title: t("settings.extra_fields.params.multi_choice"), + title: {t("settings.extra_fields.params.multi_choice")}, dataIndex: ["field", "multi_choice"], render(value, record) { if (record.field.field_type === FieldType.choice) { - return value ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false"); + return renderCodeValue(Boolean(value)); } else { return null; } }, - width: "10%", + width: 108, }, { - title: t("settings.extra_fields.params.referenced_in"), + title: {t("settings.extra_fields.params.referenced_in")}, key: "referenced_in", render: (_: unknown, record: FieldHolder) => { const formulaDependencies = formulaDependenciesByCustomFieldKey[record.field.key] || []; if (formulaDependencies.length === 0) { return {t("settings.extra_fields.referenced_in_none")}; } - const formulaDependencyList = formulaDependencies.map((item) => `${item.name} (${item.key})`).join(", "); return ( - - - {t("settings.extra_fields.referenced_in_count", { count: formulaDependencies.length })} - - + + {formulaDependencies.map((item) => ( + + setFormulaEditRequest({ key: item.key, nonce: Date.now() })}> + + {item.key} + + + + ))} + ); }, - width: "12%", + width: 132, }, { title: "", dataIndex: "operation", + key: "operation", render: (_: unknown, record: FieldHolder) => { const editing = isEditing(record); return editing ? ( - + @@ -593,10 +626,16 @@ export function ExtraFieldsSettings() { ) : ( <> - - + + + + + + + + + - - {isDesktopOperatorPanel ? ( - - + + + - {/* Show helper/operators before references so helper-first insertion flow is visually guided. */} + {/* Keep the reference-aid panel above field groups so JSON writing help stays in one place. */} : } - onClick={() => setTokensPanelCollapsed((current) => !current)} + onClick={() => + setTokensPanelCollapsedByEntity((current) => ({ + ...current, + [selectedEntityType]: !(current[selectedEntityType] ?? false), + })) + } aria-label={ tokensPanelCollapsed ? t("settings.formula_fields.formula.json_builder.show_tokens") @@ -2603,13 +3317,18 @@ export function FormulaFieldsSettings() { <>
+ {!guidedInsertionEnabled ? ( + + + {t("settings.formula_fields.formula.json_builder.copy_hint")} + + ) : null}
- {t("settings.formula_fields.formula.token_sections.helper_functions")} + {t("settings.formula_fields.formula.token_sections.operators")} - {/* Pending helper status + actions are placed in the header to keep insertion flow visible. */} - {pendingHelperHint ? ( + {guidedInsertionEnabled && pendingHelperHint ? ( {t("settings.formula_fields.formula.json_builder.pending_helper_prefix")} @@ -2659,100 +3378,250 @@ export function FormulaFieldsSettings() { ) : null} -
{renderHelperTokenGroups(true)}
+
{renderTokenCategories()}
+ {guidedInsertionEnabled ? ( +
+ { + const nextKeys = Array.isArray(keys) ? keys : [keys]; + setHelperCompatiblePanelOpenByEntity((current) => ({ + ...current, + [selectedEntityType]: nextKeys.includes("helper-compatible"), + })); + }} + > + + + + {t("settings.formula_fields.formula.reference_picker.helper_compatible")} + + + {activePendingTokenName ? ( + + {activePendingTokenName} + {helperCompatibleReferenceCount} + + ) : ( + + {t("settings.formula_fields.formula.reference_picker.no_helper_selected")} + + )} + + } + > + {showCompatibleReferenceGroups ? ( + helperCompatibleReferenceGroups.length > 0 ? ( + + {helperCompatibleReferenceGroups.map((group) => ( +
+ + + {group.label} + + {group.references.length} + +
+ {group.references.map((reference) => ( + + insertExpressionJsonReference(reference.value)} + > + {reference.label} + + + ))} +
+
+ ))} +
+ ) : ( + + {t("settings.formula_fields.formula.reference_picker.no_compatible_fields")} + + ) + ) : activePendingTokenName ? ( + + {t("settings.formula_fields.formula.json_builder.if_step_condition_operator")} + + ) : ( + + {t("settings.formula_fields.formula.reference_picker.no_helper_selected_help")} + + )} +
+
+
+ ) : null}
- - - {t("settings.formula_fields.formula.reference_picker.label")} - - - - + + + + {t("settings.formula_fields.formula.reference_picker.label")} + + + + + + setReferenceSearch(event.target.value)} + placeholder={t("settings.formula_fields.formula.reference_picker.search_placeholder")} + prefix={} + style={{ width: isDesktopLayout ? 260 : "100%" }} + />
-
- {compactReferenceOptions.map((reference) => { - const referenceCompatible = isReferenceCompatibleWithPendingHelper(reference.value); - const isSelectedForPendingHelper = Boolean( - pendingJsonHelperInsert?.selectedOperands.some( - (operand) => operand.kind === "reference" && operand.value === reference.value, - ), - ); - const disabledReason = - !referenceCompatible && pendingHelperDefinition - ? t("settings.formula_fields.formula.json_builder.reference_incompatible_reason", { - helper: pendingHelperDefinition.name, - }) - : null; - const referenceToken = ( - setHoveredTokenId(`reference-${reference.value}`) : undefined - } - onMouseLeave={ - !disabledReason - ? () => - setHoveredTokenId((current) => - current === `reference-${reference.value}` ? null : current, - ) - : undefined + {filteredReferenceGroups.length > 0 ? ( + { + const nextKeys = Array.isArray(keys) ? keys : [keys]; + setExpandedReferenceGroupsByEntity((current) => ({ + ...current, + [selectedEntityType]: nextKeys, + })); + }} + > + {filteredReferenceGroups.map((group) => ( + + + {group.label} + {group.references.length} + + + + {t( + group.scope === "current" + ? "settings.formula_fields.formula.reference_picker.current_scope" + : "settings.formula_fields.formula.reference_picker.related_scope", + )} + + {group.source === "extra" ? ( + + {t("settings.extra_fields.tab")} + + ) : null} + + } - onClick={ - !disabledReason ? () => insertExpressionJsonReference(reference.value) : undefined - } - > - {reference.label} - - ); - // Keep a stable wrapper shape for all reference tokens so disabled/tooltip states - // do not cause reflow when helper compatibility changes. - const content = ( - - - {referenceToken} - - - ); - return ( -
- {content} -
- ); - })} -
+
+ {group.references.map((reference) => { + const referenceCompatible = guidedInsertionEnabled + ? isReferenceCompatibleWithPendingHelper(reference.value) + : true; + const isSelectedForPendingHelper = Boolean( + guidedInsertionEnabled && + pendingJsonHelperInsert?.selectedOperands.some( + (operand) => extractVarReference(operand) === reference.value, + ), + ); + const disabledReason = + guidedInsertionEnabled && !referenceCompatible && pendingHelperDefinition + ? t( + "settings.formula_fields.formula.json_builder.reference_incompatible_reason", + { + helper: pendingHelperDefinition.name, + }, + ) + : null; + const tooltipTitle = disabledReason || reference.fullLabel; + return ( + {reference.fullLabel} + ) + } + > + setHoveredTokenId(`reference-${reference.value}`) + : undefined + } + onMouseLeave={ + !disabledReason + ? () => + setHoveredTokenId((current) => + current === `reference-${reference.value}` ? null : current, + ) + : undefined + } + onClick={ + !disabledReason + ? () => + guidedInsertionEnabled + ? insertExpressionJsonReference(reference.value) + : copyReferenceToClipboard(reference.value) + : undefined + } + > + {reference.label} + + + ); + })} +
+ + ))} + + ) : ( + + )}
diff --git a/client/src/utils/formulaFields.ts b/client/src/utils/formulaFields.ts index 254ea79a7..fc47ede55 100644 --- a/client/src/utils/formulaFields.ts +++ b/client/src/utils/formulaFields.ts @@ -24,12 +24,14 @@ export const FORMULA_HELPERS: FormulaHelperDefinition[] = [ name: "min", description: "Returns the smallest value from the provided arguments.", category: "math", + reference_count: 2, reference_kind: "number", }, { name: "max", description: "Returns the largest value from the provided arguments.", category: "math", + reference_count: 2, reference_kind: "number", }, { @@ -42,9 +44,16 @@ export const FORMULA_HELPERS: FormulaHelperDefinition[] = [ name: "coalesce", description: "Returns the first argument that is not null/undefined.", category: "math", + reference_count: 2, + reference_kind: "any", + }, + { + name: "cat", + description: "Concatenates values as text.", + category: "text", + reference_count: 2, reference_kind: "any", }, - { name: "cat", description: "Concatenates values as text.", category: "text", reference_kind: "any" }, { name: "upper", description: "Converts text to uppercase.", category: "text", reference_kind: "text" }, { name: "lower", description: "Converts text to lowercase.", category: "text", reference_kind: "text" }, { From 9ff74283203823fbe421c481706f934f83d0902e Mon Sep 17 00:00:00 2001 From: akira69 Date: Sun, 29 Mar 2026 18:52:12 -0500 Subject: [PATCH 6/6] fix(settings): align json-first formula field review pass --- .../pr-assets/pr14/pr14-json-first-editor.png | Bin 0 -> 176649 bytes .../pr14/pr14-json-first-settings.png | Bin 0 -> 150938 bytes client/public/locales/en/common.json | 2 +- client/src/index.tsx | 22 ----- client/src/pages/help/index.tsx | 16 ++-- .../pages/settings/formulaFieldsSettings.tsx | 83 ++++-------------- 6 files changed, 29 insertions(+), 94 deletions(-) create mode 100644 .github/pr-assets/pr14/pr14-json-first-editor.png create mode 100644 .github/pr-assets/pr14/pr14-json-first-settings.png diff --git a/.github/pr-assets/pr14/pr14-json-first-editor.png b/.github/pr-assets/pr14/pr14-json-first-editor.png new file mode 100644 index 0000000000000000000000000000000000000000..b49573cd23e9f257ba84b0b270d34ba1df38eed7 GIT binary patch literal 176649 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

yMUiR2n~F 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�SlFAuXO>K5jc&QZ-|-jmdPpSCk7%;Yo303 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

4p`c~d;V`b@N z{`t7k3$HcZMwT6q6V4j7tgrR|s8yug&_@$~F=10R7^%!96E-X|-u4xl$~jg22T*pe zGoiaX4`0SC3o|>|tZm;a<$075^ufzPd8YN#yyFrl1ywICw%XfBen3?e1y-cFCkG|^ zMUL4e1QGM(xBq!x&PMMMA>uxcvY%$d9|@J9EbJamNq6>%>lin`UGqFv^6qG_b@!#m zD_(Ek_XM&{0D*OU{M}kj;3?5#^i0RU1GOG+Ya{j@L}ku8FChAj&)jZkkAAjuv5ayw zW4=JWV3*pEnJe)|EJOPT8BU7d!)h2;5LSZf9gQwlv!`PM_$_Vox@3WKzI|P1Up+K@ z5F=m|U~|T^zyuUB8h~!FnzwTEmQWx}q}I^jKN4 zMBifs&GHq8IGv2|iU$<>mESGF*kew_XN$R_*sE_@ie1o8M4U%!;Oe!}8l+*Ac=b(P zRnc09mz%bV!Irp89SPNqcBmg;zDo&$cBbgqeFgtQG6%Hadt3ZI7*2~Dspc2mNN?V+ z!u2?|u(mk9?)XCYw|p}>vii>t`_NMP;Y_3P>CJREN0Q}A=!i)FgHcTm>eUX$I)HOU%J*rLoeO=0O^c{Zxy}M(y#d`-LZtJmy z+BfZR+S?!!#A;+azUACoyj8A?&T_l3(8lmR7_8|I^Gu8CpfKC!nhueG1CkfX8VHU2 zBJUKHali_C$CyImC)zc?R7g9+(x(gAP;(d0L$2EVhVk&_UFsYh7Fj-~ z;;U>q@oJB{tDH^tZ3Lzf-5! zrcjEl*eq@u_W)Br)SzH}t!~dCT?1+fD+k2~-<0)q`@O_NLMh^}r zld=k4*62t^?Ks|jDTa3l58 zPdNWEmc(!$*Zj|{Ba#Z|A4h?X2O>OW;do**%?qEaOlFMNK}Wd1K-tDrF<1$ggl(_h z#MEMHdm(!CrBz}ohWSiY8WWHp7+J=6yX!mVJQHO#ng>JKz zcORPZ=eBOi5I;|j>aAE%^w8!oTYMqHNR@!;7iA)x8k*Ez%C?SshxW(eymq#K91bV6 zg^PJ{t}#leDt**<;Gn*|0gvO@&(h#Z3P<8(ii>PoSLM@0cF%xU|MdbIcf6bnN;z15 zs$*>(a5r|gpu5#L7QcQ}n(`XLE`Ia!#{Gn8p2{fmjLZS!&5Tn+X)flV`(1f~u7V@C zOFlYq$D45XR{J86CO6}X`x)cLac^>wSqeYp@G?)Yg6%DpS~>6GIXF>Bl1>vvagtcC zJPphGEsuP9rAbv6Ci{Yrp#2U_9;uzarWphyJ-_4I#eB7aSL3-u>X2>K?N1;$L|#rr zDjeuS70x@!l7l%y=pP=iQI!+vNxva8L?({T+1I0N&kS!^X0RnHQ9HRE6Vu}qrFj*D~W-1ACRZBaq)~Gn_wd|?&8F=q)~^` z_?F0yp-AUKzHto#-Ipqlo1HBWZ~XLn5Uy>Wa;44eXu!(YZXV|TfN;7eSESmALjPOq%0CYsZ2jpQ!EDb+f||2OESbV z;>I}%F)^`kYcl;kRg-LO`%5LF9_?NA-BjURu`@^k9}PX-vve!BNB7`$(cE&4_p5e7 zKnh_qq`BuXrt%25LQ?eire2s|YVwY4)gaTaN{}FH}T_vOdcA30m$bsZItbTd4DlPG{SS7+d|p z9~ymba0U~xW2&Ug4>ErH^F+{@5)>mK zpfkyQht*Whf6zsfDzAy6%UAG!7t6o~9>PLX0R^q0^V_~@PmP0a$MT+x_-=Vs*t-B| zfmEwMs;DF2_kR4TZnHDs`!&RTLf*oZeNef3Hf8_e3$u~dmeX5+WmqGtdia{27vM={ z)OlQ81EZK26UFnRBn!*`6bpqRS?vIL016@cug64MG#v z>Q+@|1+&2|k9H#7Hbf&{rxeSX-K0M!qC2m3dXNO8xdq_v0mJJ(BUv|XTQAQLyhlb` zmjO4OXI?U$055!M zbO=prv$(I~$l@ z4-E~Cf$T*?BlPgdh-q6!ZZ2-M#=X)Yp0_%G<{0?E(9lE;>x3FXiF_z(_!ch%^%EMJ zXehw`{=mx;Bk=bJ8d|N}w10n!(l~JEpO07$XtMtE)85ktd;k8V!Atx1qBJxIV*kHh ztoUtjf4`i@>VZ2JmA+&`@85s$?IW#Wo1~JG(x$G5N2O*jpnVd^6VzW}ACLZfgB2;} zx660&XuJoF)c?2{fyuvr9d$)WM5LGI!N=tP-@X;i|N4k5*x_};8~pDT*~4oEI@{6W z8s2{%t3bY0Ie^9@Xz(~J5mAu$e^=u_4f4i~0$Im;_fx-F6s@tbF)|s|#@}Zqd;>%&rrLw?>kHt&;4zTz zO_O!@pNAJU`<<$l4$ie;Iq@7AByV9)LQFT_2M*-nfMDkR9D4qPTmdH^z}R_vSd{-+ zF&YngU%!4x#U@|^0ep{N-t5=FRB^bCHFz}8GFS&F>+{>Tbx;|Z1T}-Q{vbG1wp`Z30AC#$ z8KD}O1FQ5cG~JDevcIX;7>yTGK23q2EkFX-foFM~nr?!eOiEuHm1h9co>Y0`(Z^IA zNblESw@{=S_T^JexVem!(5ih&t0WSfnwo{OS=JlZH>+V4f$C(Is}$$~&nyojSh*qF z563L})KltRkS0PzwPvPL$5I1S$cz5~3WmDHTR17*ZGZ0h< zr)J@UwWPja_&Np@2f~{&=*z1I!`i;N>7N=hy|&4e%6H z0*%tApBh0*177|-G-dNtLcf8TsWik)rhMS=z*Hyz1$(xKojI9n|ah3)8pq{XK0hd^x3@e?wP+3d{A~Vpf{F z4V{}VFzuY2oGj#r63Z9f>E;H^y>CRqwH$!?-s0~vuS|WFK(O6}#0aQA?*Q%zoA3nA zXzjgT=gGNNSbtz>t_I_2z=EK9xtm$+ijffDHJT;11FIc&!q{zKfID8Bi31;|`0bCt zC%Ojg642QxK49_-q-aKbAJS#H{&z;mL)3!W5!h!>K~_t_k6dn|pJ8fbl)TW>mqay; z0vOH}mOOBdCxBK{{h<G)%E-|(1IT=r2Pr~=C({Wg=ybNpMW8ryj4iDE2WUB1z-olMSQ(si z&z%t7hO&~naJwtP>y65EgCi>fxHiIv!2<3Hs7bNNr?I^ec57cBPzGc2b`Sy+nGFRu z+SM;0bwmL`7ry(O6+lO=rGNm!=Dr3jHaK!(Jye1D0}Q^&XvnHM8*AaPI1xIRaWH%J zF$|iY0*~+9pFjv{gnS<^ZN3j$6xB37Fo53qmQAU4!;hUQz=5 z<}vE6Rur+80{Kpc+yTK1o&%h5#2*>IkTUZ8{AKVAl}E682c+Xz_GE3l%d3_x^^?a&{C{0en`PbUZ} zMeGd1Zr3LMo(?p-pTRL`8ye#_b&hsmVZ1>9k;eAlQ2W~$(tbG7y=inljQw+%Jgum# zy!7tjWS08Y*w)&9VX=Sn2L=ckop54*yDPW^5li_IJ$=@mx8%R^%%Bwlpe%SWNj%Tu zhIRLU{!3lW|92US?rDC0ehCQ)Rn_TYFSx5Qyp7b)^q1bo5D)rkod3J8D&GsR>#T%?sfZOcewmWaRM#t87% z6C1#C5I74sM}_%&WMiWdg1nJazuv3-`}eE09_0H!vC>h_%U!Gp`1h)_)T^qh7WiZ& zXEX}X8EU>5y~}41lkUDRE8NJON@nuW^wC%;k#)XbLB1EZIT2HlNV!Fdm(fUi4mUMx zabU0d?=Lr#>e~XJ?Vip!a3@NIz*%F}esZ+wC8u~Wp|{l=d%pS7^cE0RM)n#C4~WvR zCTlea!>JnyM>g~}gv^?Kcb276|LX-zHs06?+YO+a&cPTA(gGgWHarv4V-@U6F^rN& zLLJ^)9=J+4OIN$Gu3u*0n~{4Z2xKLVh! z2t4We{x~$~Sa#|Ma}^x{YV1#kM3H$jpp!xOq&)%h3r{$CsKRHaN0xzDda`4Y+T;5H z^qTER0SO5|5PAV6svKuLA4pDp26|q9F}4QK0VrIkK6IcVVzQWP&AsdgYjyWV^!M*U z5!O=RbJzvc==}Wr>kD!ho+kpyRVzF$`~$V(nY@@&03+5R9rP|y_q;4A--+qoeQItm zeD=t`ydK%@4d#OO;X*WnL`Zae{aPWw51drfjIE`7zzZtMT_VPc@)K@{l}Jx}&gqCp zsrN%cbNd`|PSnrzD?=mA7Ge*VG&!UVlnmii`31FVtm$U*ZP1r_evE1^RRk_oK;AX? zuBKtH-~GLy9=WG48YRZ{a&{P zF1&+^SBTSozeG|*?VJyIepiY&irlgOfMsEms&&ZQ{=B^#>1Utk!f=N*k(WVu38ieg zK3w%)TkaY$0WH%7(SG_FkidTdrwe=qNLk@jzhyXhJug&M_CT3_;$iPh@1-Y-@?Th& zA*|Y59Pp&=RQ8={0J+;(rd?p+Q3-IlR)Pk`T?g`kIt{2W382vZ%qLpIHZX#YpuBAN zcu;f`1iA8B5!J#XZQIaB1!K4eFym?)i7U7aWQ)bP_(jym-6ww!?=Hw?K%H6AfVY&r z41@z=@+%|!16T}Fvp$dzh6GElk}ffcGZ-cqVJK>a@Il7oA?Jr&pE_!TVdo|Fky7&j zU1pn+HHgRohrc>b0T>}tqy7F%yaF}{xV|gX#_5EW1Xd6BB-zqL=B70J z-aZE^h%Xx=h>3J|Q82sy9ul)cf75)$0PwRMC>m-`3AxstfeUD3`wfTq`zx;`?Ck8; zvW)2F!4C?OIa7&ynm4j01`a7L7T=_5d~LVGR9mINTi_bXhcQckf;d}y+h99>Q-R}c zz@dZ0L%CfMwd~&@W01d?W|~3Ovg0|v<+1?8f{$aA*AF8%!Tyi@2bt`ia41z`KwAUO zt?DX79j2XXXE^2A^adK zUm82rI9tw)&1gU%@2s#QBA*t?y%2bO$T+Ad1pdyv)Sa^JrR+mGHP$$3Wa3g*3olM*^9I!A4C3oV(!8e3^`yEdSlza=12GT_QK`; zA+?@%qw+^@Ycmfm6(9pxpbb&YQTkCWdUa2{xeqR@Pj6 z^94ALTaa~I68n-qkviSgZ@?5N`Qq64Xou)m3RNIF&!XVTjR4bim>E4Mn4Za(};vJ(3F(ID?0Ncta7L7qGfE|pV7)ZL5?pM$|gL?h0-p~KCfByqv3pl9}$e?Z$d-` z57|sT>pf*X-Y{|^`i$^fbk~>5=YIJKQ^D`RX2pH$owjqgDVDzo!o>L;KdG?aZ|j?; zpD}LLrd^QqQ~fKBW3NsTeX63vN^TWH;=WbPFx^n;%zu#71C*} zwfQ`ljsE6B6HiD~)v8{HLIE=~u@W(wQ0-)peG{527|)S+v%XKDq9IFQ!*B&T81=2< zkS37g!=uLZemLU1R@cZN+h}U{eu#Or98xHW2)bHr(-JMZ&wScGhNKQCFYJnVz|fr` zcml(R!$$zW09f*GzO76x*`BRpNThSZHi*nrT6F4185gt(af5WO>&m+DPbP92QaI^C2YSf#RAp>ZNw!rrlbhDdjs@6hL95Cy4IX;6SS}TJ3VtxZ zps#$Chs-uwW7T_6>&ND@yP|s!OSU6a(WMf#>}g)+O%>TZn%Udq&f%sn!$E6%m3h_s z+gvX&2}V4p2;OXdCwP8tAt3Ap#bb|DAfx;A2b+?GyxhF9eFg@VLJP-Cu&EF}J}>Eh ze%DEyS|J2N1+y&XmA3i2Os~@H)r+f??jXA8UGf;~vhB!>KbQG?-80(RpY2Z$5_#<2 z{xnhPAvY5!vZl2si2X1+SCZLa{~^O?>XSIsA0un$yobwW231C!6b^{l1#vcVsa88J z*I1dBK$*QsKG(spdYL299&gWJmS@pq&GYQt)%yBkjknBK$J2s5kQN-jf>B>Y(yqSe zEI%z8Lu@bQ@XKHriD7IbX#P`9ud}&?0d~Y})G{GmTQv{*?Em#o3O)((L5PMPG!=c(q=>twU!isqX zE6&ZwgmNDG0bL;_P{G*pV?E~Q{vgf=m$t&ugg!j3Ru)+QXY@~fUvrobie!_Js#cmi zRjhf_8QKWZ`{;2xAyRjkj}-(4Md1fvdv34r3!#o!Etj*^e-qKwr5?c4snP4t*PSHR zfqRbcwr*1oTC~_Wh{u5=>IqVfg#@Yt+i74_b%b);-kwL=p2PkyIMU&|Dy?HG%QECN zcj~1o9vZ$R4%|MN_g#XJ@EQCrE1w@Q&d49H{6Z#3_BaTis5996uv%zFrCNtWh`Vo* zwzk|wH*r~VK(HV82zT8|*{<^-)PfD2e$J!J}Aheheo zzCXpIFcr8FtUzGPKC~J3yH&P<6H{)@aDvw<8`VK;GAfd!(_=r;hZ^y$9Sdsit`1_EWfVOyl(~yVQ9cbE_|JV+sjZm29N&>iy39>h-chWH#J! znx$>jhGZ++#u}mbH=t+nk@eiRv8@ zCH2`*^s}}6_OgXt$XNEHHkd$%6UT4d)+BR8Zh**4`eykPdpEt=>GnzsY%6~@oFeAs z$fX()pLK6K+yflBWSUJ}(A3_b^G%rb+hcwVNAxi)nD>`=Lx>^QxPPpymUy%cdct)z zrVylnTG6i5Dv4}qjKCECc+u>=`Bym|kEjLSujs$5G!qxC9W4(y z`K>g{$}hY?Z!|~=nmy2yu8jt=IDp(z1m(%;*H%rADa&gW>5eplLYVC~E(cGyZrh;& zQA99x0B!mFxYEe4#078DEJ=~oHw>nG>KHE8+tW~XMg1thUOHP1|BM6q5gH`hH59KYUHXpPEX1=3XR&N#p^pQ% z{hmzg%DrCb!=tFN+*{U-!W!ctmdLRP(}c=mEV+|GJc371c}=LrI>mH`<2}~T_Y`4{ zU7dUCtB2eV!eQ#@-C;5rqX6$aZr-rauHt4nH3*tM*#fv9>RE^f98|^0i&i zqeQ<_1);h8S2v&wa0A%z-n13#WZSiA6K<4c{lSZ9d&1yEOIBy@nZ9GoOp- z|8|G3qhxjElxqQ76{hkC;IM7CxSoc?A3Vjo0!M z&KowTgI?9jB2 zblW1>TFzi275hbODo50dh`&@aXC{#k`3{*G#i%3pW^>r57coaC6k zi>RWpr`U!5*h^1`AHw_)KS@PaD#C|t`C+P~sN50Ng^Fo(_LYtfT-OD99I2%9m9tsJ z*8Sr$m?NP#95=k>3>u#sl{=RSF zrhPwyuLOqDaCxI?-czh*DQ547UZgC~74ejE4Y-rS7&=~@y_1?u5CMDQVlGT1`ONjn zg(zIw%B8XRuZ~*JQ_OgcJRyOk*CV4>8N&l(E6Su+85}not*5R=Xe4esWpk0^AVs}U zI+9jyol3UEjNn|zk$Xm$E$qtT+s(ESTd3K4kvY~F&B@`uUx{YOHM}D!tx+nAu7gYa zjG*b{G&aqVra(oGQ7)Kpa&$kkxU7y*uokvwzcy6FA5BKq2_Dr`dQ74ckM3GGz3JE_ zlr=rr{{6A3nMjj-7)2|L7!s9xP($ekv=lm>kQ5zRqEQpin)wx? z_u~p`EC!d78whtOol1zyg#EH<10ET~GK1X8K9_kf#I*NIi$mqU$ibqbIuzwg(or7*5#Y+_pyE~B;vL%6u>*RGOUDT#k5Svk&Nf1t7iGN}} z-pQaUtUkJVpuRodqrAX0>E5uQMBd%8tV87n7Y}Q~MkAggSyxgYrl$f z1w6!=hfdUYwau9)z^rV#Hw-mbURjAi%c8A;3>&DeZsQ$pkiYT*ZBSlSg?D)sn&nnw zzSzLEAMayEy3jfB<41sz6ncbMx+>=)YD$ra#oYI5HM%tQ{?eb$y+l*hmbg4oF&v#) zp^6PCfzT5q zm@b|(4B=~QYYRa%vsOD+!<_4;#KvaFO6AZ*LXcPHWT%0c#dZS&`WS~hUV*gGRA`)d z&GIHv^#!C=tz%7tslDF-m1=i5BJ|zZT+Q4eFqchjly8_pQANn#x3o*7Y zkw`C^LGX_8^qxQPGb{O{_V1Su(szb?t-U|Nq$ApT*_{Y^7QH(DTH%;D_sWDKdXN4D z(mTfX!G*XO3A%G@A4E({d>riBIvLH6B_y+UJXZ5jHVAN|vzuaGcz5nqpkJQFld9>7 z!bKFe+KFP<`13#)gYTmphJvouimpN;zxlG}(52!|gNl375fKf*IeL;Je^ss+TGng( ztiR?<>~L&x{q#g|NH5RT6g+p=<f%r-TBeK<%2TRdeZsY3qyP%X2^`!zI=$!y^GvB<(0ejx2a4s^?KtIY~ex(UtMVbU|qOE)^PI&kEo^64oe}&;W^{(B3!@yAcT*Huq%UW zs7@m9&6jL%0Q>}%>xJ6u6q&UVqAF{)1Ww0#_Ve>%(XGg>r8AVG6YL|4;3NPuLq5NATDOWg4`I=s#dd9`V9>~t}ov)E~SR#jkxgHB2Hig6%H%I1r<+)&mj z?+o_SOLXIW*~e7)8NO5ocG_o)8n5zeQ8b9Vq=zWaxb3gKqEkVY0|NurPDl>M@G7yS*LsEmNKykuo z<0^fmc%@4wY*KrngmvJs^W!CzW@+k3CUI)NtlnxNx(P254kyZ$c$73hg{rxo$@G?&PX%)^-Ry{%|58_T>%_7`muDnDj3pBpHVn|F_;UyV9 zR+D|HlVwe(EI_(ETq+v$<&-ItI?qpXG|ASA+2^zLx)MJ2t4i&!H%D;Ar+dsV-G9^F zHXw1DIrt+!gDoV&=nJt{sML%XLBFhWK7oCnyvcsuFZ`}J!>pb1w{pxqvw?#VC^Cl2 z3RQJMu(H&YL{qDgy)jT9@oVG+5x3sS`)ajsZJ)DsGf?Y`wlijWm7KSJe6FXInpU5; zED&zKHITEHi^AQP0^|DC2VO+z-brI5xw5}sv+ zEu_C7c>~l9%&HXobx&EgMC11GAc8Dfkl<`9XR%$i?&EWJp~J>8AkHiuLqt$haRbjUjc%^7PR$)8VG>30IZ+eC30+)qkhYU zlwE3;VZYu$(V0mWc$~MT@h;+rQ}AAFhJSsXLWz&z=x`Id4mjuJF&(PwBb?VuHMKJX zf{RhY7iu&}`e?hL`o|47xGy5iR$i(7e732Iv%>Fn<}uoQp~u#DBumrFZPMlX_`}tY zchPelEyeteN<%obSpQSW>?W0DangN{9OQO(SgFb4EIa92fyAQ9qxCf9wKqS8ttJO9 zOSls%zLig--w!zN`kwkIx}1W{Gx9syafnP@O+T7zQCa;*a-)12lp+pVtn%j*p5ey_ zoR!PSk3_X`9Mu6Awbaf0jZUJQMiqy`kRfhbrXnPM$}>~KUJ5zzwPQnllfX+sH4z@QeNthl0L`wxG-6#43bWOBig=eVXYL~lTf3_cQR7D^ zD>bj;)b5-V$~s1EzT$?+3Br>+h7!j*@tifeZ!qGz4qDGMq{DRU1|ryW&9|fY%zm(C zs8l~6nl#sc5V$Xg*^`5|S@Y!Y`0AJkR|7B!Ep%AbCS7H`m+X|QjH1r=ZxAg$35+~sY4*52 zFmrswZhvQ*btCq95JC$T`XNJgrzI7a;6aUsFv~)maz^o3D9-#$D=b zc3Hl$?yLy}tfIizn^^|2ygN#ot&vT<0i&c-l!RV1k*UMl@k{^{%Jkgoepf}>%&u~bWweWN$RLMoiX+1c&}?u4l+nY!U*s*h@&-M z|MiV^L+r4o&QuJJrN*JfOoGzTUNcsBd=q{n#a{$(A4g>&cSOtA9Y3?j=k~G-QFUT~ zg(AA_Ru6lB;-eDKm-}}Bjz9;^ckS=%MB!>;RcR(-b3H>U$q91x4IQNK9E1)%No@`L z!Q8TlWQ%gv#-=4kOpb*Ko1M$(9xTUf-OSR8m$7%8qH=cf5V%U}kQ5y%M&4mfEYP-9 zf2xkacouf+QnW)l4!K;u9O$1CaUy-Xh(Cvg?gLU%Shq1<)~cR3YmjMxnZUYfn0f`L zEAJ!qSxsqo-~RS;Z`Pf{3bbYNQq63ry;uXNDNlH!@Pgj{M#P}~ixUsZI!n*4^3Qwv zZI3g}z8Xs#eR=ls2Mt|a*Td{hzjFi{A8*{xG2$b-Wp8Y&lf`-Ckks+S!?7WUJd4K) zDQ7JFW7nUt8+EJ(K7j;z@t4J`VcUW+@0s@~5_e11wmW#IFC9oU)%%Q`JlTXyEOK%>H4t|&6qpTX z)dXr5tlWBiu& zf4zV{&TKPIf2|C!J?1{=EPvdR=C5dXb<&jac-;!d&!o)HeJYX-sF%xIxaC?UIVQp>*bhoAX&U<`QaQ9h3gUr z!U`Z=HgUZR3nY|k>Ip1H|H1F`Rb~f3NKQDIlna@&0$>U%E|CwwNrlvdUIta*?y3#E zk>8wKTTm8(6chrCo0%bkx0em6yyYEq!XUtfF0!3l1}6qYyCpw(GgYKu^fS+x zTSv`~rZqM;u1e)}h%|{^Uk`?nUzlO+2KHr|%C{cS8_FU)YrDk}^F$C~pw$K6Ld#J5 zb}Vac5S<}#gf08yl{HtH6frb8?NwSBM%+u=PIJPR5##n38@bchXhn>Ks6>q_6|R`% zLFf;$&M_bjXg5SW+}*KN&sE>Z7!2RFDtjF2#ou>`%E_(}GmtKkzqydDIiEUAu(Gcj z;v_tpxnOY|8&D^N5Mecvre3ZbE@yP^AKtNQV|i&6yNHBDEo8N83F^7Rn!_+F2Ur>z zJjiY~#_=);+E+9FWeN-AHVSkf1HRXOT`@U1^bu#L+HlN`hvR9)EepVTj zdSm)>yFTsZE%}ce)Do2GZTC_jtFpe0#z6RACar+urAu0Mx>t;)zeaAcM*T~j{huUg zi{Fwrp8Ref{;jgQn{eX#XLTv7JN-}kUq4&8*Iw=M$!g>?S|$*?$3h%<+tgbqzE@GP zP+`z`ZTeBX`sB#Z~cNK)E<{x%R8L8rzB=FRk{06&b8UDap1MHtI!|c0mKB2aLE5)29Mo(xSP;b z^dmAPE%cMmdQr-sP@mu8>(4GA%aWJE2}@TAdn|yzP5nwV-Ak#*LJw;pg3=$3`?TC! zN-;I7Gc@blUMGo}ti0H-(X06n!CYAW(Qfutj^S{d+_37^yxS+yn10IoKEi4Crk4yAg0_gCqMthHlK{lNU=MR?(d2)Q-39SMdR;4K(Wfd z6!!nyi_`pXA2Yac`+@uBreIS@&Apbu>EYG>l`A3TKIgB@k7ngEiOP9dwk`cZ|4=FK zE2JEPC6sdKN?;%p3wVI|$$Yf{4iWI@jiO#}yiypv7P+!&XqPuWuY4sgmkHORO!P>Q z5gYUO02(y|3(zh#Sy^M?7>knT_nfLEhz$DvnQ>z4$W8cxy*eBU&?c_j%IZYWQl$3nSCvMF~+Ft5^BmF({ zKHQOCN@}e#xGRKDf`w{t3u&Rx!u5s!kKWEa z9O^gv`_rN_?FJ>4FqW~FC5Z|V!&tH;OC&qlOOd68WZ%Xv!Wbb-me5#A_ACk6lRa5V z_AK+9kMg~)=X(Bqe!uyntE*=2na{nP``qU~@7GxpifnA|DQ&LGR*5PnONmG{75p2a z3xBhz%~r#7$aM@K3ENOoMsR5bA8HI@sEA?H<`bqtp}havn0%*xb&U}}Q!;eh=}q%= zGjH$wL(=DS8t|A$H_h8=afvrHOj)*39$LC5hfb;&nm$+`C>{`=dBmI+)L(wztXyO7 z=Gz^A<=Vj4>}0^66rz0QfCd)-elfvu)Ji)0pn6Dwdt*f&_5uIrj59k3Qz9D3Og}Au?`{!zu)R+JFP`?5;-<9#;_-#M^*NyZj6#cHv zaV11V`e?qqddd2uD<&nNI)D2HQSWGzim)g=q~_peF7DBAqI%ghEvGXxIS6+>geXQK z(Qp2k&2N5r?Rt{gYBqV#Xp=xp=I}NYx9Mizgrwbia>2^^+y{EvCWU-qXs%#W?jxn3 znCi-WCdsTPq>Ajjx9DzVgjqKhTfcCH={^cjn`IXnAI;9z17%ypxD;e$5QUT)qlszs)Cuw4B9i+?c-aFP0BslVsUpxL*X+)r-XR2G%zb^h#X5aOsV^B@~*vm$PK3rFbm7 z;cAy0Dezl}eOVc^bjK#aeyBe0b6q3Tj$@Ou>EG~YkR2CA5WT zG2n5q{>}-yzZ*76YW5jU($CV079H`=8A18!y0Mci+JYa%Z<;9h&1$lA&sjKJ|2a3{ z25MIw4jb>=>-p%vX0|ifd5v6KKKFNyp-`t-6;BKzX>igj5;seE!`k`KQpw7*dp;=T% z{$I+M(b|iCvMy9ChoSQZ9 zBjS%QBnXRUPBY-&=W`{Vu}n-M^_DoLEq)^s5gP%m(qc}_HQXz3n@f4fa!`p#&zi0? zZSeGzLzrr0m2j9E8pNUO%cl2{_oC52rue_kEea)FeMZjjxCOO< zYTg(dU1G1R(v#OWt>@^J7jQx&NyX8dlpz{6DVkHf6smP#!q&MOuhNI%SMgi4AVueq zNZSp?XqLM`@_|?^)QiU1gX-JK3KuWuMKmu%3Ky&m_mt>QHn?^K|6PK>InxAoUU|?g^s1&n-Xv z5KF>D0A{r7h>nu^IEi0aPDZ#v_dM_IZG>FrKFLl01RmfQ-CAu=av&4q&`5v#V!sk+ zZU%qvq~XY~{Tj8=H-*X{h!?s@teyuIszAW_&%6o#rC+(1_&4`ou%P#USLpDWH_2eG z4|}x89_TB-Po`~*d2X&U62rK%h6A#bNT$yXxOoNY24`;MA0*ab@0&yhxpJhI^>*7-33Uy8AD}_lf zFo%c#;F-f>uf&7<-e_}`4BX9D`W^ndgPO;_l#1WDSHyuFn&7n-=ZPi$G=CoKLqjML z*WtW!mojhN_mhc+aQE%J&BD8VEi4Wn&0UZ!Z?B`wL#GJmM*aa7@w{ae4cHC?@*WEU`jSu8)hYHh0+ z8sFP@E6y^0%WTnKD;M+U;NTd&@l&Hn$2LGZ%;5ri`b`lH%(^aioQLElC@-}2V~x;r z6tfL=-4h8Ab99r>>`hIi<*aM6jZOE({5OtsrB`sNO_n+Xl2LKtm}!kgUxoLcQTzkD z?YKC&tjRN}Xpi-){|)=&U#0dqYG%x_0wHV0D-Sf@e@cFTf^gi@xm*&HYH>y*Z`07B zq~e$L$Bf%zh#y>I*@%u^-ixz@)+^5@-u~B6a`@-c=k9;*9{v=d9?Noj2#9u@?kjllv|&c$&|H6IR_`te1s&iInATO{m}f8TdX(m1N_(G06=7J;i|(YD@3 zMU0lLLlWuJaN4Vf!nn__?LM~kkh1my+Z|pO>r^cm?RTf6quH#~43TPH7Adas`lb9ejM9IM$?^&zrW2di-k52-G@o)v+sx*7RE zDe@=2t6Gm-y$d0X2gUACeWky8UDr`&%Kz2=%&@eJx6Wptrcq0?REdh?X}4Kx(NduZ z+;fb+feUlD*+I_r;_Xqkr@AVKc**`VC`7AmPp}vcCA5EF0Pj_yY`%JYXuKNEd?lU~ zW5<6K-EFNF7pf~pBl2}Rid#Yzn{3JD3DWvjuis!nY{=7U6wx7X&+fSsjgye*UAfsTa4bQ@&Tqx(X?HQ3rTp9arVTD_b#gc{Oi#R{-O_6DdkBS!Z(fi; zOPfbcBayu1%OEsfBIwn z)5-)l`WTFNs=`dL1Z>WK52fJ61yxqE4@Zc|nc)|+a@*_OPlU%wF!+^9)yqQ9RQoTo z?b#Z^;@MJz`uc#r%khKF(JD0~8@CZXtbY8F(9Yv>8+L4;Q|s_F{{ma6Z>)TDzg>8H z5@#CgWW5xPe8bAn^Yd()wNp$FxJTJvFaC|?^Z?5vzrPxk~=8tf*7-YPp@|DUNLl>cpO9Y>|dIp!Jp z4|<%K{)`=tW20%z+VY{3f0t1B>*_8p6*J}R4GrL}a-h1!jhC`f_eHaHpkFL_^^)p z$Zq$qe(~O`-s2tkjg%Ea+i=#mq+^HrAhw5`BQc1o9fW@;9qU*@A4;|dQ7cFebAjUj z>eVZdfMvs#CEWZA>CnOP_gB3t+W?yR7WK}qw*!S@g>oLJ$<{9_4Wge(bV5O~XaO~t zZhwz-W{AEQ6#K603!Qoe7Di}!QKZH~4hXA(@&*EeEZxE%kaiCuH4D#?qo{{rjh8Or z2$3c-?)(?q0^*BXl&$El<<^{$17obPiO*;VfU?+TW@aj}BI$r}Aisdfzw=a=k&IR^ zR8Eeq8+JTaa!Au)<83`V*r$irh z@i^^xWreKpXv`EEYIp>f8o+Uw1pOw+azJmFc$oe+Kv?b;W(R$l@I4*2Vc?iIxJ%;)T17#zRX=g_I| z)C3B0xT!J6urCZD!aOvIuwr~~3`9;*;}LMoNk$!f8PsU|J1{(e_h=p)7u{Kz5dxfd zLQ)spq7Y+G;ma`3}kJd@&45+&(aTKbM!;P;O&9(b)*{2r=$;vE(=QY z0WCq$G2R!$1^qhICUyO3-=amiF1&tKYb+NALtx zL_x>#q!tRv1{nOQ(s!2*3m0 z2klrJy06qF|JpMEIm*T@0KkaTr@*Fn5!U|e9LQ({{jCkXR_fzf(PV;U})I?%}o_Q_BDqf~=O8UqKLb(KX zh&wF6hA_x3O9ViGB^xNZmSGL_4+E}2ZvSey%K7wL)qV(9pz~@nX%K|X$UUdeer7S${`eD!-+9Vf-Y(4u_QS$iK18{UwC|kSguTv zPGKY8^=~VdT3Vp70=Zo*XwrDJtzg_oGMu4^>noRMi+%tV?djC@am@sP1VTln^;Skt zTwMEg*+!bu3dE2PWaC;F5C{kFzZx_L@>}4MruTe7GZooU$9I*1s?888Y#am5Mnx|F zWCF3DwlE3Fl3SV|xIes~|H;?4Fr|P@km8PO(se2UVQ#$tPsycv1xS#h!kuu2T^d1o zpt=kquX3CNjU4yYG0xkrnj9Tr;&&-$K1(+DGC{|(mB^n`@C#HY!J!BO%OR%~8Jo`y z?-Ttvod_@lNWwLMl>~OIeHn7D>?zLEM0xRQsquSP7!96bGUJzfNA2*U76SDRUr)Sk>?ftA8}q5Kekwj!bu|AwbO0hv^TM zg09NwqghHOhy^+~()BNcih53U>0HBhM8mnNd-XBf4*pyNZ7}#?{1vOfYJ+@u0jf?Q zwG%;HJkw(J$mX5q_VvkKMFDrhkNbs$Id5RNQcb8BRwfDTN6lu@3B6=xWd{V=f&O*+ zs@U`n=iQ%bp2G#o_tuoc!2*ZER)$6Iz}vMeAg{LraLA4W=cL4g7C8bvKkOGnI3Z_= zt#GYclw;5bHV-fZU_4tu1M-2K^785#Q_$9n25ppr#C4k8Z)zUV|5d94S8oh*cg9Qz zMk4xi9Tw<>sSt}*6YR8119#yOOD530ju^m&M6%{LK3tuB%bt2qg^^n0Sc>l`Mf3C? zGgwcGq4UWO73LXlZrefNr}Aat8e|G&xR-0~S z_gQ`P0B(Wq!Gezx*~5$MTp-<_ zd6nP%`5kmWveCKD!XIK5pQMI0U#dyF1V(Da5W|^u5oM_g>5yWMDikmWKGCEG`r#A> zoiEY`WsRl*M9|5HaAW}(T%I>*XqZX~bgJMh?94#n!XWp_U0^wd$oqvq#^f z*q8Se308h>68laoq+4)rZ^*O5Sej&d%Z})64Z%bVh5j?G zFX8+6xCi)Uz(nc3w7`D=pfdYkdqOFu&KP5`67(T10^D}E` z4eAA?XCO*ZJV1^WE(6XEs7sLDO7QJ>w(%Rv zX<+u76B&3kb9dN;jFgV*6y~))$$JX3yq6WJ4+lkHFydp-zA*T!z>$OnGdnYg0Pir# zAqX$Zk8UU|2}`Wa=(;D^mKq-OCx%w*$%lca2BF=BqGf`@*@3sXlJ6#4m_d3z$RsS5 zX8J|#tDYUk;cz`fvORHD{G+AVt*!`*nnH(lmzkT9VJ9#R!SSrF(~U2o)>X58B4rU0 z+*M2o;2`H2aL=cynEw#mWInzKKJiPmROJP$cw6B_(_Z}u@O zf9Tk#0AmoZq)qt#RD!<|%$wQIfHr`H7NoaaCMz8kPtB z?y2j8zH5nt&9PK|#JvG9jp%cbUFO5i_-6)ge!$-Lk_j=6up9c^5WR-#QH>P1VI}w@ zCNmXnqk&0*j*vvDs&^sN%Fm1lbtyO`R1xSP~7hyo4^r9j*^mf+F!Ho@S6OvQSVCb%GH`79dKOe zlVB&u(H?`UM36mmdr}vfePF}Q;J5*H&J~IoBbIeMo6Sde1F`KL+Jvn( z4E*Fo6et;?lSuh_uT>o<-Bs)`4B5`W7OCLV$upV%-U^h9qE23%hP}tt?49brLc_x` z29zw_rhTPfj`zGT_Axgd?Qi~4lnC6jTwjFBpnD0t%|2jR6mEDzf;c$U9zb)_eSo&@ z>- zUX)_ysYXhnf}t-1JIn~xFQVP)P-;_;$Awld)^HCvpjT$UwJk9P@`n93vNO-GXs^kF zixPT{>MO$L&6+$T`%1v^N4Sl^Z<(K;Z*SKer2&|o*UG&c^65T8vG1Sk27ti?=X`Mt z>*c4#wdq=!U!u<#w_ihY))&%}k1KzY)r?N=NEDz<2^FqQ|I912Y^(Vi6A>CZ3-cq} zpsdF$TRX2Jxg$-LWn2S&4xI4p`9UbSbOAgHu`TWn;y!-WLBMHf9@R_a$J z?D_M!gmuu4M2@|7fE81VH1Tv5y!}VoBSb|a*Es;$+u#7S)W_w{$beEy-dQS8Pe0^*c1a$s z##l8U>V|H*f8)H?o7rg6b(r!^u9CfeVrOz-E;vPK2A-X6 zwkpwCtTWaI;3zT_?_7DM&NYgg8JFsf=2Vzh<9mB&A~^uW5f}GT-b?}>d2HqN7{!h- zccF2=SnQwuY@o&d=Es|TANFY*`IqIEkRZ8fuDlmrq`YA$1rf5JZsiFHXA?6so_t~N ze8@oqWY!8_+?y!p+&V)oUQ*>$IO^f8Bi8TKMII4erE8QAJISe^Y6zaUgYA=fSMZ<< z9;S$3haH^W!hc#o5#3)b?=Rk<+|2MP_Ss1Q9G19*(d;#&Y-7Rgy1K!7y~hQrZQr@PuGJ{LiP0vM4Oec)@&fo21^ zNET=-;8En8H=T!#S^3r!7%w<8!qp;iA5D-WdaZQ2((OytpPd{DpQE zg3d%H^b<~%`1`Q@A%<`ruLdW;zMoIM<(q#}x@&o+0US$}c`b$!ijyN<`|*6%JgD5HXGN0}>3-S%C1b z=A{YPiEl0JyWv(HXW6Y} { - registrations.forEach((registration) => { - void registration.unregister(); - }); - }); - } - if ("caches" in window) { - void caches.keys().then((keys) => { - keys.forEach((key) => { - void caches.delete(key); - }); - }); - } -} - const container = document.getElementById("root") as HTMLElement; const root = createRoot(container); diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx index d5f689e17..b55e830df 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -351,13 +351,15 @@ export const Help = () => { JSON Logic - The editor is JSON-first. The reference area below the editor groups available field references, operators, + The editor is JSON-first. The reference-aid area below the editor groups available JSON references, operators, and helpers so you can search, inspect examples, and copy exact values while composing your expression. - Field References expose the exact variable paths available to the selected entity. For - example, {`weight`} maps to {`{"var":"weight"}`} and{" "} - {`extra.purchase_date`} maps to {`{"var":"extra.purchase_date"}`}. + Field References expose the exact variable paths available to the selected entity. The + current entity includes both built-in fields and its own extra.<key> fields. Related + entities contribute built-in paths only in this PR. For example, {`weight`} maps to{" "} + {`{"var":"weight"}`} and {`extra.purchase_date`} maps to{" "} + {`{"var":"extra.purchase_date"}`}. Operators and Helper Functions show valid JSON examples you can copy @@ -389,7 +391,7 @@ export const Help = () => { }} > - Operators + JSON Operators {JSON_OPERATOR_GROUPS.map((group) => ( @@ -418,7 +420,7 @@ export const Help = () => { - Helper Functions + JSON Helpers {FORMULA_HELPER_GROUPS.map((group) => ( @@ -452,7 +454,7 @@ export const Help = () => { Variables come from available field references for the selected entity, including built-in fields (for - example {`created_at`}) and custom fields (for example{" "} + example {`created_at`}) and current-entity custom fields (for example{" "} {`extra.purchase_date`}). diff --git a/client/src/pages/settings/formulaFieldsSettings.tsx b/client/src/pages/settings/formulaFieldsSettings.tsx index b6bcc3c0b..bc5ca8c89 100644 --- a/client/src/pages/settings/formulaFieldsSettings.tsx +++ b/client/src/pages/settings/formulaFieldsSettings.tsx @@ -146,8 +146,8 @@ const SAMPLE_VALUE_PLACEHOLDERS: Record = { }; const EXTRA_REFERENCE_PREFIXES: Record = { vendor: ["extra."], - filament: ["extra.", "vendor.extra."], - spool: ["extra.", "filament.extra.", "filament.vendor.extra."], + filament: ["extra."], + spool: ["extra."], }; const JSON_LOGIC_OPERATOR_GROUPS: Array<{ key: string; operators: string[] }> = [ { key: "logical", operators: ["if", "and", "or", "!"] }, @@ -326,15 +326,6 @@ const REFERENCE_PICKER_GROUPS: Record( + () => ({ + margin: 0, + fontFamily: token.fontFamilyCode || "monospace", + fontSize: Math.max(token.fontSizeSM - 1, 11), + lineHeight: 1.4, + whiteSpace: "pre-wrap", + color: token.colorTextLightSolid, + background: "transparent", + }), + [token.colorTextLightSolid, token.fontFamilyCode, token.fontSizeSM], + ); const referenceGroupTokenListStyle = useMemo( () => ({ display: "flex", @@ -1319,10 +1303,6 @@ export function FormulaFieldsSettings({ editRequest, onEditRequestHandled }: For EXTRA_REFERENCE_PREFIXES[selectedEntityType].forEach((prefix) => { if (prefix === "extra.") { (configuredFields.data || []).forEach((field) => extraReferenceGroups.push(`${prefix}${field.key}`)); - } else if (prefix === "filament.extra.") { - (filamentConfiguredFields.data || []).forEach((field) => extraReferenceGroups.push(`${prefix}${field.key}`)); - } else if (prefix === "filament.vendor.extra." || prefix === "vendor.extra.") { - (vendorConfiguredFields.data || []).forEach((field) => extraReferenceGroups.push(`${prefix}${field.key}`)); } }); // Suggest both built-in fields and configured extra fields so users can compose formulas @@ -1333,17 +1313,8 @@ export function FormulaFieldsSettings({ editRequest, onEditRequestHandled }: For () => ({ ...Object.fromEntries((configuredFields.data || []).map((field) => [`extra.${field.key}`, field] as const)), - ...Object.fromEntries( - (filamentConfiguredFields.data || []).map((field) => [`filament.extra.${field.key}`, field] as const), - ), - ...Object.fromEntries( - (vendorConfiguredFields.data || []).map((field) => [`vendor.extra.${field.key}`, field] as const), - ), - ...Object.fromEntries( - (vendorConfiguredFields.data || []).map((field) => [`filament.vendor.extra.${field.key}`, field] as const), - ), }) as Record, - [configuredFields.data, filamentConfiguredFields.data, vendorConfiguredFields.data], + [configuredFields.data], ); const referenceGroups = useMemo(() => { const entityNames: Record = { @@ -2013,28 +1984,12 @@ export function FormulaFieldsSettings({ editRequest, onEditRequestHandled }: For const tooltipContent = disabledReason || (isOperator ? ( -

+        
           {JSON_LOGIC_OPERATOR_SNIPPETS[tokenDefinition.name] ??
             JSON.stringify({ [tokenDefinition.name]: [] }, null, 2)}
         
) : helper ? ( -
+        
           {JSON.stringify({ [helper.name]: buildHelperPlaceholderArguments(helper) }, null, 2)}
         
) : undefined); @@ -3559,7 +3514,7 @@ export function FormulaFieldsSettings({ editRequest, onEditRequestHandled }: For disabledReason ? ( tooltipTitle ) : ( - {reference.fullLabel} + {reference.fullLabel} ) } >
+ {children} {formItem} + {formItem} +