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..f4ef28375 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,156 @@
"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_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."
+ },
+ "formula_fields": {
+ "help_links": {
+ "formula": "Help: Formula Extra Fields",
+ "formula_json": "Help: JSON Logic",
+ "formula_tokens": "Help: Token Groups"
+ },
+ "available_functions": {
+ "label": "Token categories:",
+ "value": "Operators, helper functions, and field references are grouped in the editor."
+ },
+ "surfaces": {
+ "show": "Show Pages",
+ "edit": "Edit",
+ "list": "Tables",
+ "template": "Template Selections",
+ "action": "Action",
+ "derived": "Derived"
+ },
+ "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",
+ "path": "Template & API Path",
+ "name": "Name",
+ "description": "Description",
+ "expression": "Expression",
+ "expression_json": "Formula Extra Field Expression (JSON Logic)",
+ "surfaces": "Display In",
+ "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"
+ },
+ "tooltips": {
+ "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."
+ },
+ "expression_json_copy_tooltip": "Copy expression JSON to clipboard",
+ "expression_json_copied": "Expression copied!",
+ "sample_values": "Sample Values (JSON)",
+ "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.",
+ "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",
+ "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 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": "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",
+ "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": "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."
+ }
+ }
}
},
"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..6112bad7c 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 { FormulaFieldSurface, 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,39 @@ 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, FormulaFieldSurface.list),
+ [formulaFields.data],
+ );
+ // 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(
+ () => listFormulaFields.map((field) => `derived.${field.key}`),
+ [listFormulaFields],
+ );
+ 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 +201,13 @@ export const FilamentList = () => {
sorter: true,
};
+ 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)));
+ };
+
return (
(
@@ -191,20 +234,27 @@ export const FilamentList = () => {
label: extraField?.name ?? column_id,
};
}
+ if (column_id.indexOf("derived.") === 0) {
+ const formulaField = listFormulaFields.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 (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..263e75914 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 { FormulaFieldSurface, 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, FormulaFieldSurface.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.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 bf493e6fd..e09f82f85 100644
--- a/client/src/pages/help/index.tsx
+++ b/client/src/pages/help/index.tsx
@@ -1,27 +1,170 @@
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." },
+ ],
+};
+// Keep help operator groups aligned with the interactive token panel in formula settings.
+const JSON_OPERATOR_GROUPS: Array<{ label: string; operators: string[] }> = [
+ { label: "Logical / Conditional", operators: ["if", "and", "or", "!"] },
+ { label: "Comparison", operators: ["==", "!=", "<", "<=", ">", ">="] },
+ { label: "Arithmetic", operators: ["+", "-", "*", "/", "%", "floor"] },
+];
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 Refresh before
+ saving.
+
+
+ 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.`}.
+
+
+ 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.
+
+
+
+
+ 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: Completed days between two datetimes (integer)
+
+ Variable definitions:
+
+ {`{"first_used":"2026-03-01T10:00:00Z","last_used":"2026-03-09T16:00:00Z"}`}
+
+ Expression JSON:
+
+ {`{"floor":[{"days_between":[{"var":"first_used"},{"var":"last_used"}]}]}`}
+
+ Result: {`8`}
+
+
+
+ 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 Refresh +{" "}
+ 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"}`}.
+
+
+ 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:{" "}
+
+ jsonlogic.com
+
+ {" · "}
+
+ operations
+
+ {" · "}
+
+ JSONLint
+
+
+
+ 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.
+
+
+ If a formula includes Template Selections, it can be referenced in templates as{" "}
+ {`{derived.your_key}`} (for example, {`{derived.days_between_events}`}).
+