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 000000000..b49573cd2 Binary files /dev/null and b/.github/pr-assets/pr14/pr14-json-first-editor.png differ diff --git a/.github/pr-assets/pr14/pr14-json-first-settings.png b/.github/pr-assets/pr14/pr14-json-first-settings.png new file mode 100644 index 000000000..0326d78fb Binary files /dev/null and b/.github/pr-assets/pr14/pr14-json-first-settings.png differ 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..7ddc32154 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", @@ -335,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", @@ -353,7 +361,176 @@ "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.", + "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." + }, + "formula_fields": { + "help_links": { + "formula": "Help: Formula Extra Fields", + "formula_json": "Help: JSON Logic", + "formula_tokens": "Help: Writing Formula JSON" + }, + "available_functions": { + "label": "Token categories:", + "value": "Searchable grouped references, operators, and helpers are available beside the raw JSON 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 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", + "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. 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", + "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 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 {...}).", + "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": "Reference Aids", + "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 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. Current-entity extra fields are supported alongside built-in related-entity references.", + "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": "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", + "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 reference aids", + "hide_tokens": "Hide reference aids", + "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.", + "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 token input.", + "helper_only": "Insert placeholders" + }, + "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/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..b55e830df 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -1,27 +1,190 @@ 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 +, 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{" "} + 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 + + + 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. 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 + into the editor, then adjust as needed for your formula. + + + 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 + referencing another formula field. Formula outputs are available in API/template usage via{" "} + {`derived.`}. + + + The grouped reference area can be collapsed if you want to focus only on the editor and preview panels. + + +
+ + Available JSON References, Operators, and Helpers + +
+ + JSON Operators + + + {JSON_OPERATOR_GROUPS.map((group) => ( +
+
+ {group.label} +
+ + {group.operators.map((operator) => ( + + {operator} + + ))} + +
+
+ + ))} + + + + JSON Helpers + + + {FORMULA_HELPER_GROUPS.map((group) => ( + +
+ {t(`settings.formula_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 current-entity 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}`}). +
  • +
+ ); }; 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: (

{ if (isNew) { return true; @@ -57,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 ( -

); @@ -280,22 +297,30 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps ) : null; - return ; + return ( + + ); }; 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); const [newField, setNewField] = useState(null); + const [formulaEditRequest, setFormulaEditRequest] = useState(null); 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,50 +468,83 @@ 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 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) @@ -496,44 +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")}, + 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")}; + } + return ( + + {formulaDependencies.map((item) => ( + + setFormulaEditRequest({ key: item.key, nonce: Date.now() })}> + + {item.key} + + + + ))} + + ); + }, + width: 132, }, { title: "", dataIndex: "operation", + key: "operation", render: (_: unknown, record: FieldHolder) => { const editing = isEditing(record); return editing ? ( - + @@ -543,27 +626,76 @@ export function ExtraFieldsSettings() { ) : ( <> - - - del(record.field)} - disabled={editingKey !== ""} - okText={t("buttons.delete")} - cancelText={t("buttons.cancel")} - > - - + + +
+ {children} {formItem} + {formItem} +
{newField == null && ( @@ -635,6 +776,10 @@ export function ExtraFieldsSettings() { /> )} + setFormulaEditRequest(null)} + /> {contextHolder} ); diff --git a/client/src/pages/settings/formulaFieldsSettings.tsx b/client/src/pages/settings/formulaFieldsSettings.tsx new file mode 100644 index 000000000..bc5ca8c89 --- /dev/null +++ b/client/src/pages/settings/formulaFieldsSettings.tsx @@ -0,0 +1,3718 @@ +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, + CopyOutlined, + DeleteOutlined, + EditOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PlusOutlined, + QuestionCircleOutlined, + SearchOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { + Button, + Checkbox, + Col, + Collapse, + 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, useCallback, useDeferredValue, 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"; +import { useSavedState } from "../../utils/saveload"; + +const BUILTIN_REFERENCE_SUGGESTIONS: Record = { + 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", "filament": {"weight": 1000, "price": 24.99, "color_hex": "#FF00FF", "vendor": {"name": "Example Vendor"}}}', +}; +const EXTRA_REFERENCE_PREFIXES: Record = { + vendor: ["extra."], + filament: ["extra."], + spool: ["extra."], +}; +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. +// PREVIEW_PANEL_WIDTH is reused beside Sample Values so the compact preview stays visually aligned. +const OPERATOR_PANEL_WIDTH = 244; +const INLINE_OPERATOR_PANEL_HEIGHT = 188; +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 PendingTokenInsertState = { + tokenName: string; + tokenKind: "helper" | "operator"; + selectedOperands: unknown[]; + pendingIfComparisonOperator?: string | null; + pendingIfComparisonOperands?: unknown[]; +}; +type PendingOperatorOperandConstraint = { + mode: "comparison-operator" | "expression"; + allowedReferenceKinds: "scalar" | ReferenceValueKind[]; + allowedTokenKinds: "any" | ReferenceValueKind[]; + selectedKind?: ReferenceValueKind | null; +}; +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 PendingHelperHintState = { + helper: string; + selected: number; + total: number; + allowHelperOnly: boolean; + stepLabelKey?: string; +}; +type FormulaResultTypeHint = "number" | "text" | "boolean" | "date" | "datetime" | "time" | "unknown"; +type ReferenceSemanticKind = "generic" | "color_hex"; +type ReferencePickerGroupDefinition = { + key: string; + labelType: "entity" | "extra"; + entityType: EntityType; + source: "builtin" | "extra"; + prefix: string; + excludedPrefixes?: string[]; + defaultExpanded: boolean; + scope: "current" | "related"; +}; +type ReferencePickerOption = { + value: string; + label: string; + fullLabel: string; + searchText: string; +}; +type ReferencePickerGroup = ReferencePickerGroupDefinition & { + label: string; + references: ReferencePickerOption[]; +}; +type TokenDefinition = { + kind: "operator" | "helper"; + name: string; +}; +type TokenCategory = { + key: string; + label: string; + tokens: TokenDefinition[]; +}; + +type FormulaFieldEditRequest = { + key: string; + nonce: number; +}; + +type FormulaFieldsSettingsProps = { + editRequest?: FormulaFieldEditRequest | null; + onEditRequestHandled?: () => void; +}; + +const REFERENCE_PICKER_GROUPS: Record = { + vendor: [ + { + key: "vendor-builtins", + labelType: "entity", + entityType: EntityType.vendor, + source: "builtin", + prefix: "", + defaultExpanded: true, + scope: "current", + }, + { + key: "vendor-extra", + labelType: "extra", + entityType: EntityType.vendor, + source: "extra", + prefix: "extra.", + defaultExpanded: true, + scope: "current", + }, + ], + filament: [ + { + key: "filament-builtins", + labelType: "entity", + entityType: EntityType.filament, + source: "builtin", + prefix: "", + defaultExpanded: true, + scope: "current", + }, + { + key: "filament-extra", + labelType: "extra", + entityType: EntityType.filament, + source: "extra", + prefix: "extra.", + defaultExpanded: true, + scope: "current", + }, + { + key: "vendor-builtins", + labelType: "entity", + entityType: EntityType.vendor, + source: "builtin", + prefix: "vendor.", + excludedPrefixes: ["vendor.extra."], + defaultExpanded: false, + scope: "related", + }, + ], + spool: [ + { + key: "spool-builtins", + labelType: "entity", + entityType: EntityType.spool, + source: "builtin", + prefix: "", + defaultExpanded: true, + scope: "current", + }, + { + key: "spool-extra", + labelType: "extra", + entityType: EntityType.spool, + source: "extra", + prefix: "extra.", + defaultExpanded: true, + scope: "current", + }, + { + key: "filament-builtins", + labelType: "entity", + entityType: EntityType.filament, + source: "builtin", + prefix: "filament.", + excludedPrefixes: ["filament.vendor."], + defaultExpanded: false, + scope: "related", + }, + { + key: "vendor-builtins", + labelType: "entity", + entityType: EntityType.vendor, + source: "builtin", + prefix: "filament.vendor.", + defaultExpanded: false, + scope: "related", + }, + ], +}; + +function getDefaultExpandedReferenceGroups(entityType: EntityType): string[] { + return REFERENCE_PICKER_GROUPS[entityType].filter((group) => group.defaultExpanded).map((group) => group.key); +} + +function referenceMatchesGroup(reference: string, group: ReferencePickerGroupDefinition): boolean { + if (group.source === "extra") { + return reference.startsWith(group.prefix); + } + if (group.prefix === "") { + return !reference.includes("."); + } + if (!reference.startsWith(group.prefix)) { + return false; + } + return !(group.excludedPrefixes || []).some((prefix) => reference.startsWith(prefix)); +} + +function compactReferenceLabel(reference: string, prefix: string): string { + if (!prefix) { + return reference; + } + return reference.startsWith(prefix) ? reference.slice(prefix.length) : reference; +} + +// 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", + }, +}; + +const NUMERIC_REFERENCE_LEAFS = new Set([ + "id", + "price", + "density", + "diameter", + "weight", + "initial_weight", + "spool_weight", + "remaining_weight", + "used_weight", + "remaining_length", + "used_length", + "settings_extruder_temp", + "settings_bed_temp", + "empty_spool_weight", +]); +const DATETIME_REFERENCE_LEAFS = new Set(["registered", "created_at", "first_used", "last_used"]); +const BOOLEAN_REFERENCE_LEAFS = new Set(["archived"]); + +function inferBuiltinReferenceKind(reference: string): ReferenceValueKind { + const leaf = reference.split(".").filter(Boolean).at(-1) || reference; + if (NUMERIC_REFERENCE_LEAFS.has(leaf)) { + return "number"; + } + if (DATETIME_REFERENCE_LEAFS.has(leaf)) { + return "datetime"; + } + if (BOOLEAN_REFERENCE_LEAFS.has(leaf)) { + return "boolean"; + } + return "text"; +} + +function inferReferenceSemantic(reference: string): ReferenceSemanticKind { + const leaf = reference.split(".").filter(Boolean).at(-1) || reference; + return leaf === "color_hex" ? "color_hex" : "generic"; +} + +function isScalarReferenceKind(kind: ReferenceValueKind): boolean { + return ["text", "number", "datetime", "boolean"].includes(kind); +} + +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 (["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"; +} + +function toDerivedFieldType(typeHint: FormulaResultTypeHint): DerivedFieldType | null { + if (typeHint === "number") { + return DerivedFieldType.number; + } + 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; +} + +function resultTypeHintToReferenceKind(typeHint: FormulaResultTypeHint): ReferenceValueKind { + switch (typeHint) { + case "number": + return "number"; + case "boolean": + return "boolean"; + case "date": + case "datetime": + case "time": + return "datetime"; + case "text": + return "text"; + default: + return "unknown"; + } +} + +function inferOperandValueKind( + operand: unknown, + referenceKindByName: Record, +): ReferenceValueKind | null { + const referencedField = extractVarReference(operand); + if (referencedField) { + return referenceKindByName[referencedField] || "unknown"; + } + return resultTypeHintToReferenceKind(inferExpressionJsonType(operand)); +} + +function extractVarReference(operand: unknown): string | null { + if (!operand || typeof operand !== "object" || Array.isArray(operand)) { + return null; + } + const entries = Object.entries(operand as Record); + if (entries.length === 1 && entries[0][0] === "var" && typeof entries[0][1] === "string") { + return entries[0][1]; + } + return null; +} + +function tokenResultTypeHint(name: string): FormulaResultTypeHint { + if (name === "if" || ["==", "!=", "<", "<=", ">", ">=", "!", "and", "or"].includes(name)) { + return "boolean"; + } + if ( + [ + "+", + "-", + "*", + "/", + "%", + "floor", + "abs", + "min", + "max", + "round", + "year", + "month", + "day", + "hour", + "minute", + "second", + "timestamp", + "days_between", + "hours_between", + "hue_from_hex", + "length", + ].includes(name) + ) { + return "number"; + } + if (["cat", "trim", "upper", "lower", "left", "right"].includes(name)) { + return "text"; + } + if (["date_only", "today"].includes(name)) { + return "date"; + } + if (name === "time_only") { + return "time"; + } + if (name === "coalesce") { + return "unknown"; + } + return "unknown"; +} + +export function FormulaFieldsSettings({ editRequest, onEditRequestHandled }: FormulaFieldsSettingsProps) { + const { entityType } = useParams<{ entityType: EntityType }>(); + const selectedEntityType = entityType as 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 [tokensPanelCollapsedByEntity, setTokensPanelCollapsedByEntity] = useSavedState< + Partial> + >("formula-fields-builder-collapsed", {}); + const [expandedReferenceGroupsByEntity, setExpandedReferenceGroupsByEntity] = useSavedState< + Partial> + >("formula-fields-reference-groups", {}); + const [helperCompatiblePanelOpenByEntity, setHelperCompatiblePanelOpenByEntity] = useSavedState< + Partial> + >("formula-fields-helper-compatible-open", {}); + const [hoveredTokenId, setHoveredTokenId] = useState(null); + const [referenceSearch, setReferenceSearch] = useState(""); + 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 deferredReferenceSearch = useDeferredValue(referenceSearch.trim().toLowerCase()); + + const tokensPanelCollapsed = tokensPanelCollapsedByEntity[selectedEntityType] ?? false; + const expandedReferenceGroupKeys = + expandedReferenceGroupsByEntity[selectedEntityType] ?? getDefaultExpandedReferenceGroups(selectedEntityType); + const helperCompatiblePanelOpen = helperCompatiblePanelOpenByEntity[selectedEntityType] ?? false; + const niceName = t(`${selectedEntityType}.${selectedEntityType}`); + const guidedInsertionEnabled = false; + const sectionBodyStyle = { marginTop: 0, fontSize: token.fontSize, lineHeight: 1.7 }; + const tokenPanelStyle = useMemo( + () => ({ + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, + padding: 8, + background: token.colorBgContainer, + }), + [token.colorBgContainer, token.colorBorderSecondary, token.borderRadiusLG], + ); + const tokenCategoryStyle = useMemo( + () => ({ + borderRadius: token.borderRadius, + border: `1px solid ${token.colorBorderSecondary}`, + background: token.colorFillQuaternary, + padding: "5px 8px", + minHeight: 0, + }), + [token.borderRadius, token.colorBorderSecondary, token.colorFillQuaternary], + ); + const denseTokenCategoryStyle = useMemo( + () => ({ + ...tokenCategoryStyle, + display: "flex", + flexDirection: "column", + alignItems: "stretch", + gap: 3, + padding: "5px 8px 6px", + }), + [tokenCategoryStyle], + ); + const tokenCategoryLabelStyle = useMemo( + () => ({ + display: "block", + lineHeight: 1.05, + margin: 0, + }), + [], + ); + const tokenCategoryBodyStyle = useMemo( + () => ({ + display: "flex", + flexWrap: "wrap", + gap: 2, + alignItems: "flex-start", + justifyContent: "flex-start", + }), + [], + ); + const tooltipCodeStyle = useMemo( + () => ({ + 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", + flexWrap: "wrap", + gap: 6, + alignItems: "flex-start", + }), + [], + ); + const tokenCategoryColumnsStyle = useMemo( + () => ({ + display: "grid", + gridTemplateColumns: + screens.lg || screens.xl || screens.xxl + ? "repeat(3, minmax(0, 1fr))" + : screens.md + ? "repeat(2, minmax(0, 1fr))" + : "repeat(1, minmax(0, 1fr))", + gap: 6, + alignItems: "start", + }), + [screens.lg, screens.md, screens.xl, screens.xxl], + ); + const tokenCategoryColumnStyle = useMemo( + () => ({ + display: "flex", + flexDirection: "column", + gap: 6, + minWidth: 0, + alignSelf: "start", + }), + [], + ); + const isDesktopLayout = Boolean(screens.lg || screens.xl || screens.xxl); + 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 filamentConfiguredFields = useGetFields(EntityType.filament); + const vendorConfiguredFields = useGetFields(EntityType.vendor); + 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]; + + useEffect(() => { + if (!derivedModalOpen) { + setReferenceSearch(""); + } + }, [derivedModalOpen]); + + useEffect(() => { + if (!pendingJsonHelperInsert?.tokenName) { + return; + } + setHelperCompatiblePanelOpenByEntity((current) => ({ + ...current, + [selectedEntityType]: true, + })); + }, [pendingJsonHelperInsert?.tokenName, selectedEntityType, setHelperCompatiblePanelOpenByEntity]); + + const labeledField = (labelKey: string, tooltipKey: string) => ( + + {t(labelKey)} + + + + + ); + + const referenceOptions = useMemo(() => { + const extraReferenceGroups: string[] = []; + EXTRA_REFERENCE_PREFIXES[selectedEntityType].forEach((prefix) => { + if (prefix === "extra.") { + (configuredFields.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], ...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], + ); + const referenceGroups = useMemo(() => { + const entityNames: Record = { + vendor: t("vendor.vendor"), + filament: t("filament.filament"), + spool: t("spool.spool"), + }; + + return REFERENCE_PICKER_GROUPS[selectedEntityType] + .map((group) => { + const groupLabel = + group.labelType === "entity" + ? entityNames[group.entityType] + : `${entityNames[group.entityType]} ${t("settings.extra_fields.tab")}`; + + const references = referenceOptions + .filter((reference) => referenceMatchesGroup(reference, group)) + .map((reference) => { + const shortLabel = compactReferenceLabel(reference, group.prefix); + return { + value: reference, + label: shortLabel, + fullLabel: `{${reference}}`, + searchText: `${shortLabel} ${reference}`.toLowerCase(), + }; + }); + + return { + ...group, + label: groupLabel, + references, + }; + }) + .filter((group) => group.references.length > 0); + }, [referenceOptions, selectedEntityType, t]); + const filteredReferenceGroups = useMemo(() => { + if (!deferredReferenceSearch) { + return referenceGroups; + } + + return referenceGroups + .map((group) => ({ + ...group, + references: group.references.filter((reference) => reference.searchText.includes(deferredReferenceSearch)), + })) + .filter((group) => group.references.length > 0); + }, [deferredReferenceSearch, referenceGroups]); + const visibleReferenceGroupKeys = useMemo( + () => + deferredReferenceSearch + ? [...new Set([...expandedReferenceGroupKeys, ...filteredReferenceGroups.map((group) => group.key)])] + : expandedReferenceGroupKeys, + [deferredReferenceSearch, expandedReferenceGroupKeys, filteredReferenceGroups], + ); + // 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 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[]; + } + 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 tokenCategories = useMemo( + () => [ + { + key: "logical", + label: t("settings.formula_fields.formula.token_categories.logical"), + tokens: JSON_LOGIC_OPERATOR_GROUPS.find((group) => group.key === "logical")!.operators.map((name) => ({ + kind: "operator", + name, + })), + }, + { + key: "comparison", + label: t("settings.formula_fields.formula.token_categories.comparison"), + tokens: JSON_LOGIC_OPERATOR_GROUPS.find((group) => group.key === "comparison")!.operators.map((name) => ({ + kind: "operator", + name, + })), + }, + { + key: "math", + label: t("settings.formula_fields.formula.token_categories.math"), + tokens: ["+", "-", "*", "/", "%", "floor", "abs", "min", "max", "round", "coalesce"].map((name) => ({ + kind: JSON_LOGIC_OPERATOR_SNIPPETS[name] ? ("operator" as const) : ("helper" as const), + name, + })), + }, + { + key: "text", + label: t("settings.formula_fields.formula.token_categories.text"), + tokens: FORMULA_HELPER_GROUPS.find((group) => group.key === "text")!.helpers.map((helper) => ({ + kind: "helper", + name: helper.name, + })), + }, + { + key: "datetime", + label: t("settings.formula_fields.formula.token_categories.datetime"), + tokens: FORMULA_HELPER_GROUPS.find((group) => group.key === "datetime")!.helpers.map((helper) => ({ + kind: "helper", + name: helper.name, + })), + }, + { + key: "dynamic", + label: t("settings.formula_fields.formula.token_categories.dynamic"), + tokens: FORMULA_HELPER_GROUPS.find((group) => group.key === "dynamic")!.helpers.map((helper) => ({ + kind: "helper", + name: helper.name, + })), + }, + { + key: "date_diff", + label: t("settings.formula_fields.formula.token_categories.date_diff"), + tokens: FORMULA_HELPER_GROUPS.find((group) => group.key === "date_diff")!.helpers.map((helper) => ({ + kind: "helper", + name: helper.name, + })), + }, + { + key: "color", + label: t("settings.formula_fields.formula.token_categories.color"), + tokens: FORMULA_HELPER_GROUPS.find((group) => group.key === "color")!.helpers.map((helper) => ({ + kind: "helper", + name: helper.name, + })), + }, + ], + [t], + ); + const tokenCategoriesByKey = useMemo( + () => Object.fromEntries(tokenCategories.map((category) => [category.key, category])), + [tokenCategories], + ); + const orderedTokenCategoryColumns = useMemo(() => { + const pick = (keys: string[]) => + keys.map((key) => tokenCategoriesByKey[key]).filter((category): category is TokenCategory => Boolean(category)); + + if (screens.lg || screens.xl || screens.xxl) { + return [ + pick(["logical", "text", "color"]), + pick(["comparison", "datetime"]), + pick(["math", "dynamic", "date_diff"]), + ]; + } + + if (screens.md) { + return [pick(["logical", "comparison", "math", "dynamic", "color", "date_diff"]), pick(["text", "datetime"])]; + } + + return [tokenCategories]; + }, [screens.lg, screens.md, screens.xl, screens.xxl, tokenCategories, tokenCategoriesByKey]); + const referenceKindByName = useMemo(() => { + const map: Record = Object.fromEntries( + BUILTIN_REFERENCE_SUGGESTIONS[selectedEntityType].map((reference) => [ + reference, + BUILTIN_REFERENCE_KIND_HINTS[selectedEntityType][reference] || inferBuiltinReferenceKind(reference), + ]), + ); + + (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; + }); + (filamentConfiguredFields.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[`filament.extra.${field.key}`] = fieldKind; + }); + (vendorConfiguredFields.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[`vendor.extra.${field.key}`] = fieldKind; + map[`filament.vendor.extra.${field.key}`] = fieldKind; + }); + + return map; + }, [configuredFields.data, filamentConfiguredFields.data, selectedEntityType, vendorConfiguredFields.data]); + const referenceSemanticByName = useMemo( + () => Object.fromEntries(referenceOptions.map((reference) => [reference, inferReferenceSemantic(reference)])), + [referenceOptions], + ); + 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; + }; + const getPendingTokenOperandCount = (state: PendingTokenInsertState): number => { + if (state.tokenKind === "operator") { + return getOperatorOperandCount(state.tokenName); + } + const helper = helperByName[state.tokenName]; + return helper ? getHelperReferenceCount(helper) : 0; + }; + const buildPendingTokenSnippet = (state: PendingTokenInsertState, operands: unknown[]) => { + return { + [state.tokenName]: operands.slice(0, getPendingTokenOperandCount(state)), + }; + }; + const activeOperatorInsertState = useMemo(() => { + if (pendingJsonHelperInsert?.tokenKind === "operator") { + return { + operator: pendingJsonHelperInsert.tokenName, + selectedOperands: pendingJsonHelperInsert.selectedOperands, + requiredOperandCount: getOperatorOperandCount(pendingJsonHelperInsert.tokenName), + pendingIfComparisonOperator: pendingJsonHelperInsert.pendingIfComparisonOperator, + pendingIfComparisonOperands: pendingJsonHelperInsert.pendingIfComparisonOperands, + }; + } + return pendingOperatorInsert; + }, [getOperatorOperandCount, pendingJsonHelperInsert, pendingOperatorInsert]); + // `if` guided mode starts by collecting a comparison operator for the condition node. + const isAwaitingIfComparisonOperator = useMemo( + () => + activeOperatorInsertState?.operator === "if" && + activeOperatorInsertState.selectedOperands.length === 0 && + !activeOperatorInsertState.pendingIfComparisonOperator, + [activeOperatorInsertState], + ); + // 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 helperAllowsReference = (helper: FormulaHelperDefinition, reference: string): boolean => { + const referenceKind = referenceKindByName[reference] || "unknown"; + const referenceSemantic = referenceSemanticByName[reference] || "generic"; + + if (helper.name === "hue_from_hex") { + return referenceSemantic === "color_hex" && !reference.startsWith("extra.") && !reference.includes(".extra."); + } + + if (helper.name === "cat") { + return isScalarReferenceKind(referenceKind); + } + + if (helper.name === "coalesce") { + if (!isScalarReferenceKind(referenceKind)) { + return false; + } + const selectedReference = pendingJsonHelperInsert?.selectedOperands + .map((operand) => extractVarReference(operand)) + .find((operand): operand is string => Boolean(operand)); + if (!selectedReference) { + return true; + } + const selectedKind = referenceKindByName[selectedReference] || "unknown"; + return selectedKind === "unknown" ? true : referenceKind === selectedKind; + } + + const requiredKind = helper.reference_kind ?? "any"; + if (requiredKind === "any") { + return isScalarReferenceKind(referenceKind); + } + return referenceKind === requiredKind; + }; + const pendingHelperDefinition = useMemo(() => { + if (!pendingJsonHelperInsert || pendingJsonHelperInsert.tokenKind !== "helper") { + return null; + } + return helperByName[pendingJsonHelperInsert.tokenName] || null; + }, [helperByName, pendingJsonHelperInsert]); + const pendingOperatorOperandConstraint = useMemo(() => { + if (!activeOperatorInsertState) { + return null; + } + + const resolveMatchingConstraint = ( + allowedReferenceKinds: ReferenceValueKind[] | "scalar", + selectedOperands: unknown[], + ): PendingOperatorOperandConstraint => { + const selectedKind = + selectedOperands.length > 0 ? inferOperandValueKind(selectedOperands[0], referenceKindByName) : null; + return { + mode: "expression", + allowedReferenceKinds, + allowedTokenKinds: Array.isArray(allowedReferenceKinds) ? allowedReferenceKinds : "any", + selectedKind, + }; + }; + + if ( + activeOperatorInsertState.operator === "if" && + activeOperatorInsertState.selectedOperands.length === 0 && + !activeOperatorInsertState.pendingIfComparisonOperator + ) { + return { + mode: "comparison-operator", + allowedReferenceKinds: "scalar", + allowedTokenKinds: "any", + }; + } + + if (activeOperatorInsertState.operator === "if" && activeOperatorInsertState.selectedOperands.length === 0) { + const comparisonOperator = activeOperatorInsertState.pendingIfComparisonOperator; + const selectedComparisonOperands = activeOperatorInsertState.pendingIfComparisonOperands || []; + if (comparisonOperator && ["<", "<=", ">", ">="].includes(comparisonOperator)) { + return resolveMatchingConstraint(["number", "datetime"], selectedComparisonOperands); + } + if (comparisonOperator && ["==", "!="].includes(comparisonOperator)) { + return resolveMatchingConstraint("scalar", selectedComparisonOperands); + } + return { + mode: "expression", + allowedReferenceKinds: "scalar", + allowedTokenKinds: "any", + }; + } + + if (activeOperatorInsertState.operator === "if") { + return { + mode: "expression", + allowedReferenceKinds: "scalar", + allowedTokenKinds: "any", + }; + } + + if (["+", "-", "*", "/", "%", "floor"].includes(activeOperatorInsertState.operator)) { + return { + mode: "expression", + allowedReferenceKinds: ["number"], + allowedTokenKinds: ["number"], + }; + } + + if (["and", "or", "!"].includes(activeOperatorInsertState.operator)) { + return { + mode: "expression", + allowedReferenceKinds: ["boolean"], + allowedTokenKinds: ["boolean"], + }; + } + + if (["<", "<=", ">", ">="].includes(activeOperatorInsertState.operator)) { + return resolveMatchingConstraint(["number", "datetime"], activeOperatorInsertState.selectedOperands); + } + + if (["==", "!="].includes(activeOperatorInsertState.operator)) { + return resolveMatchingConstraint("scalar", activeOperatorInsertState.selectedOperands); + } + + return { + mode: "expression", + allowedReferenceKinds: "scalar", + allowedTokenKinds: "any", + }; + }, [activeOperatorInsertState, referenceKindByName]); + const matchesAllowedReferenceKinds = ( + referenceKind: ReferenceValueKind, + allowedKinds: "scalar" | ReferenceValueKind[], + selectedKind?: ReferenceValueKind | null, + ) => { + if (allowedKinds === "scalar") { + if (!isScalarReferenceKind(referenceKind)) { + return false; + } + } else if (!allowedKinds.includes(referenceKind)) { + return false; + } + if (!selectedKind || selectedKind === "unknown") { + return true; + } + return referenceKind === selectedKind; + }; + const isTokenCompatibleWithPendingOperator = (tokenName: string): boolean => { + if (!pendingOperatorOperandConstraint) { + return true; + } + if (pendingOperatorOperandConstraint.mode === "comparison-operator") { + return IF_CONDITION_COMPARISON_OPERATORS.has(tokenName); + } + if (pendingOperatorOperandConstraint.allowedTokenKinds === "any") { + return true; + } + const tokenKind = resultTypeHintToReferenceKind(tokenResultTypeHint(tokenName)); + if (!pendingOperatorOperandConstraint.allowedTokenKinds.includes(tokenKind)) { + return false; + } + if (!pendingOperatorOperandConstraint.selectedKind || pendingOperatorOperandConstraint.selectedKind === "unknown") { + return true; + } + return tokenKind === pendingOperatorOperandConstraint.selectedKind; + }; + const getHelperDisabledReason = (helper: FormulaHelperDefinition): string | null => { + if (pendingJsonHelperInsert?.tokenKind === "operator") { + return t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); + } + if (pendingOperatorOperandConstraint && !isTokenCompatibleWithPendingOperator(helper.name)) { + return t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); + } + + // 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) => helperAllowsReference(helper, reference)); + // 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 + .map((operand) => extractVarReference(operand)) + .find((operand): operand is string => Boolean(operand)); + if (!selectedReference) { + return null; + } + if (!helperAllowsReference(helper, selectedReference)) { + return t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: helper.name }); + } + } + + return null; + }; + const getOperatorDisabledReason = (operator: string): string | null => { + if (pendingJsonHelperInsert) { + return t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: operator }); + } + if (!pendingOperatorOperandConstraint) { + return null; + } + if (pendingOperatorOperandConstraint.mode === "comparison-operator") { + return IF_CONDITION_COMPARISON_OPERATORS.has(operator) + ? null + : t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: operator }); + } + return isTokenCompatibleWithPendingOperator(operator) + ? null + : t("settings.formula_fields.formula.json_builder.helper_incompatible_reason", { helper: operator }); + }; + const isReferenceCompatibleWithPendingHelper = (reference: string): boolean => { + if (pendingHelperDefinition) { + return helperAllowsReference(pendingHelperDefinition, reference); + } + if (!pendingOperatorOperandConstraint || pendingOperatorOperandConstraint.mode !== "expression") { + return true; + } + const referenceKind = referenceKindByName[reference] || "unknown"; + return matchesAllowedReferenceKinds( + referenceKind, + pendingOperatorOperandConstraint.allowedReferenceKinds, + pendingOperatorOperandConstraint.selectedKind, + ); + }; + const helperCompatibleReferenceGroups = useMemo(() => { + if (!pendingHelperDefinition && !pendingOperatorOperandConstraint) { + return []; + } + return referenceGroups + .map((group) => ({ + ...group, + references: group.references.filter((reference) => isReferenceCompatibleWithPendingHelper(reference.value)), + })) + .filter((group) => group.references.length > 0); + }, [ + isReferenceCompatibleWithPendingHelper, + pendingHelperDefinition, + pendingOperatorOperandConstraint, + referenceGroups, + ]); + const helperCompatibleReferenceCount = useMemo( + () => helperCompatibleReferenceGroups.reduce((sum, group) => sum + group.references.length, 0), + [helperCompatibleReferenceGroups], + ); + const activePendingTokenName = pendingJsonHelperInsert?.tokenName ?? pendingOperatorInsert?.operator ?? null; + const showCompatibleReferenceGroups = Boolean( + pendingHelperDefinition || pendingOperatorOperandConstraint?.mode === "expression", + ); + const buildHelperPlaceholderArguments = (helper: FormulaHelperDefinition): Array<{ var: string }> => { + const referenceCount = getHelperReferenceCount(helper); + if (referenceCount <= 0) { + return []; + } + if (helper.name === "cat") { + return Array.from({ length: referenceCount }, (_, index) => ({ var: `text_${index + 1}` })); + } + if (helper.name === "coalesce") { + return Array.from({ length: referenceCount }, (_, index) => ({ var: `value_${index + 1}` })); + } + 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 copyTextToClipboard = useCallback( + async (text: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(text); + messageApi.success(successMessage); + } catch { + messageApi.error("Failed to copy to clipboard."); + } + }, + [messageApi], + ); + const copyReferenceToClipboard = useCallback( + (reference: string) => + void copyTextToClipboard(reference, t("settings.formula_fields.formula.reference_picker.reference_copied")), + [copyTextToClipboard, t], + ); + const copyOperatorSnippetToClipboard = useCallback( + (operator: string) => + void copyTextToClipboard( + JSON_LOGIC_OPERATOR_SNIPPETS[operator] ?? JSON.stringify({ [operator]: [] }, null, 2), + t("settings.formula_fields.formula.json_builder.operator_copied"), + ), + [copyTextToClipboard, t], + ); + const copyHelperSnippetToClipboard = useCallback( + (helper: FormulaHelperDefinition) => + void copyTextToClipboard( + JSON.stringify({ [helper.name]: buildHelperPlaceholderArguments(helper) }, null, 2), + t("settings.formula_fields.formula.json_builder.helper_copied"), + ), + [copyTextToClipboard, t], + ); + const getTokenCategoryBodyStyle = (categoryKey: string): CSSProperties => { + if (categoryKey === "math") { + return { + ...tokenCategoryBodyStyle, + gap: 1, + }; + } + if (["comparison", "date_diff", "logical"].includes(categoryKey)) { + return { + ...tokenCategoryBodyStyle, + gap: 2, + }; + } + return tokenCategoryBodyStyle; + }; + + const renderUnifiedTokenChip = (tokenDefinition: TokenDefinition, categoryKey: string) => { + const isOperator = tokenDefinition.kind === "operator"; + const helper = isOperator ? null : helperByName[tokenDefinition.name]; + const disabledReason = guidedInsertionEnabled + ? isOperator + ? getOperatorDisabledReason(tokenDefinition.name) + : helper + ? getHelperDisabledReason(helper) + : null + : null; + const tokenId = `${tokenDefinition.kind}-${tokenDefinition.name}`; + const isHovered = hoveredTokenId === tokenId; + const isDenseCategory = ["comparison", "math", "date_diff", "logical"].includes(categoryKey); + const isSymbolLike = /^[!<>=+\-*/%]+$/.test(tokenDefinition.name); + const isMathCategory = categoryKey === "math"; + const denseFontSize = isMathCategory + ? Math.max(token.fontSizeSM - 1, 11) + : isDenseCategory + ? token.fontSizeSM + : token.fontSize; + const densePaddingInline = isSymbolLike ? 4 : isMathCategory ? 4 : isDenseCategory ? 5 : 6; + 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); + + return ( + + setHoveredTokenId(tokenId) : undefined} + onMouseLeave={ + !disabledReason ? () => setHoveredTokenId((current) => (current === tokenId ? null : current)) : undefined + } + onClick={ + !disabledReason + ? () => { + if (guidedInsertionEnabled && isOperator) { + insertExpressionJsonOperator(tokenDefinition.name); + return; + } + if (guidedInsertionEnabled && helper) { + insertExpressionJsonHelper(helper); + return; + } + if (isOperator) { + copyOperatorSnippetToClipboard(tokenDefinition.name); + return; + } + if (helper) { + copyHelperSnippetToClipboard(helper); + } + } + : undefined + } + > + {tokenDefinition.name} + + + ); + }; + + const renderTokenCategories = () => ( +
+ {orderedTokenCategoryColumns.map((column, columnIndex) => ( +
+ {column.map((category) => ( +
+ + {category.label} + +
+ {category.tokens.map((tokenDefinition) => renderUnifiedTokenChip(tokenDefinition, category.key))} +
+
+ ))} +
+ ))} +
+ ); + + 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 = useCallback( + (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); + }, + [derivedForm], + ); + + useEffect(() => { + if (!editRequest) { + return; + } + + const requestedField = (derivedFields.data || []).find((field) => field.key === editRequest.key); + if (requestedField) { + openEditDerived(requestedField); + onEditRequestHandled?.(); + return; + } + + if (!derivedFields.isLoading) { + onEditRequestHandled?.(); + } + }, [derivedFields.data, derivedFields.isLoading, editRequest, onEditRequestHandled, openEditDerived]); + + 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) => { + const pendingState = pendingJsonHelperInsert; + if (!pendingState) { + 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 ?? pendingState.tokenName, + }), + ); + return; + } + + const requiredOperandCount = getPendingTokenOperandCount(pendingState); + const selectedOperands = [...pendingState.selectedOperands, { var: reference }]; + if (selectedOperands.length < requiredOperandCount) { + setPendingJsonHelperInsert({ + ...pendingState, + selectedOperands, + }); + return; + } + + insertExpressionJsonSnippet(JSON.stringify(buildPendingTokenSnippet(pendingState, selectedOperands), 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 = getPendingTokenOperandCount(pendingState); + const selectedOperands = [...pendingState.selectedOperands, { [helper.name]: [] }]; + if (selectedOperands.length < requiredReferenceCount) { + setPendingJsonHelperInsert({ + ...pendingState, + selectedOperands, + }); + return; + } + // Allow date-diff helpers to consume dynamic today() as an operand instead of inserting it standalone. + insertExpressionJsonSnippet(JSON.stringify(buildPendingTokenSnippet(pendingState, selectedOperands), 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({ tokenName: helper.name, tokenKind: "helper", 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 (pendingJsonHelperInsert?.tokenKind === "operator" && pendingJsonHelperInsert.tokenName === "if") { + setPendingJsonHelperInsert({ + ...pendingJsonHelperInsert, + pendingIfComparisonOperator: operator, + pendingIfComparisonOperands: [], + }); + } else if (pendingOperatorInsert) { + setPendingOperatorInsert({ + ...pendingOperatorInsert, + pendingIfComparisonOperator: operator, + pendingIfComparisonOperands: [], + }); + } else { + return; + } + messageApi.info( + t("settings.formula_fields.formula.json_builder.pending_helper", { + helper: "if", + selected: 1, + total: 5, + }), + ); + return; + } + + if (pendingOperatorInsert) { + if (operator === "if") { + messageApi.info(t("settings.formula_fields.formula.json_builder.nested_if_raw_json")); + return; + } + setPendingJsonHelperInsert({ + tokenName: operator, + tokenKind: "operator", + selectedOperands: [], + }); + messageApi.info( + t("settings.formula_fields.formula.json_builder.pending_helper", { + helper: operator, + selected: 0, + total: getOperatorOperandCount(operator), + }), + ); + 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. + await setDerivedField.mutateAsync({ + key, + params: { + name: values.name, + description: values.description || undefined, + result_type: currentDerivedResultType, + 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, + result_type: currentDerivedResultType, + }); + + 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); + } + } + }, + [currentDerivedResultType, 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: 150, + fixed: "left", + render: (value: string) => ( + + {value} + + ), + }, + { + title: {t("settings.formula_fields.formula.columns.path")}, + key: "path", + width: 190, + render: (_: unknown, record) => ( + {`derived.${record.key}`} + ), + }, + { + title: {t("settings.formula_fields.formula.columns.name")}, + dataIndex: "name", + key: "name", + width: 180, + }, + { + title: {t("settings.formula_fields.formula.columns.expression")}, + dataIndex: "expression_json", + key: "expression", + width: 460, + 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: 240, + // 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: 140, + fixed: "right", + render: (_: unknown, record) => ( + + +
+ ), + }} + 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) => { + if (expressionJsonProgrammaticValueRef.current !== null) { + if (value === expressionJsonProgrammaticValueRef.current) { + expressionJsonProgrammaticValueRef.current = null; + return; + } + expressionJsonProgrammaticValueRef.current = null; + } + const currentExpressionValue = + (derivedForm.getFieldValue("expression_json") as string | undefined) || ""; + if (value === currentExpressionValue) { + return; + } + if (pendingJsonHelperInsert) { + setPendingJsonHelperInsert(null); + } + if (pendingOperatorInsert) { + setPendingOperatorInsert(null); + } + derivedForm.setFieldValue("expression_json", value); + }} + /> +
+ + + + + +
+
+
+ {/* Keep the reference-aid panel above field groups so JSON writing help stays in one place. */} + + + + + {t("settings.formula_fields.formula.json_builder.operators_title")} + + + + + + {t("settings.formula_fields.help_links.formula_tokens")} + + + + + ) : null} + + ) : null} + +
{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")} + + + + + + setReferenceSearch(event.target.value)} + placeholder={t("settings.formula_fields.formula.reference_picker.search_placeholder")} + prefix={} + style={{ width: isDesktopLayout ? 260 : "100%" }} + /> + +
+ {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} + + + } + > +
+ {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} + + + ); + })} +
+
+ ))} +
+ ) : ( + + )} +
+
+
+ + + ) : 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/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..92165a1a0 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 { 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"; @@ -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,39 @@ 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, 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], + ); // Function for opening an ant design modal that asks for confirmation for archiving a spool const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => { @@ -256,6 +292,13 @@ export const SpoolList = () => { 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 ( ( @@ -300,20 +343,27 @@ export const SpoolList = () => { 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)); }, }} > @@ -457,6 +507,21 @@ export const SpoolList = () => { 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: 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..c0cead4f3 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 { FormulaFieldSurface, 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, FormulaFieldSurface.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.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 8fc3cb468..0c40fe5ac 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 { FormulaFieldSurface, 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,43 @@ 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, 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; @@ -108,6 +143,13 @@ export const VendorList = () => { 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 ( ( @@ -134,20 +176,27 @@ export const VendorList = () => { 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(`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 (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..803ec91bc 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 { FormulaFieldSurface, 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, FormulaFieldSurface.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.formula_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..fc47ede55 --- /dev/null +++ b/client/src/utils/formulaFields.ts @@ -0,0 +1,691 @@ +import { FormulaFieldSurface, 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_count: 2, + reference_kind: "number", + }, + { + name: "max", + description: "Returns the largest value from the provided arguments.", + category: "math", + reference_count: 2, + 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_count: 2, + reference_kind: "any", + }, + { + name: "cat", + description: "Concatenates values as text.", + category: "text", + reference_count: 2, + 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 { + // 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; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +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; + } + 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; + } + + 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; + + 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; +} + +// 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, depth + 1)); + 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, depth + 1); + } + return; + } + + args.forEach((arg) => collectFormulaReferencesFromJsonLogic(arg, references, depth + 1)); +} + +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(FormulaFieldSurface.template)); +} + +export function getFormulaFieldsForSurface( + fields: DerivedField[] | undefined, + 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, normalizedScope); + } + } 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..9cf129b58 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -19,6 +19,16 @@ export enum EntityType { spool = "spool", } +// Shared surface identifiers for formula fields across settings, list/show pages, and templates. +export enum FormulaFieldSurface { + show = "show", + edit = "edit", + list = "list", + template = "template", + action = "action", + derived = "derived", +} + export interface FieldParameters { name: string; order: number; @@ -34,6 +44,35 @@ export interface Field extends FieldParameters { entity_type: EntityType; } +export enum DerivedFieldType { + number = "number", + text = "text", + boolean = "boolean", + date = "date", + datetime = "datetime", + time = "time", +} + +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 +173,95 @@ 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; + result_type?: DerivedFieldType; + } + >({ + mutationFn: async ({ expression_json, sample_values, result_type }) => { + const response = await fetch(`${getAPIURL()}/field/derived/${entity_type}/preview`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + expression_json, + sample_values, + result_type, + }), + }); + + if (!response.ok) { + throw new Error((await response.json()).message); + } + + return response.json(); + }, + }); +} diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py index 36e63d845..6d36c30ad 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,104 @@ 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( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this derived field is for")], + body: DerivedFieldPreviewRequest, +) -> DerivedFieldPreviewResponse | JSONResponse: + 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()) + + +@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", @@ -89,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 3e3f859af..3dc242d3a 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=include_derived) + payload: list[Filament] = [] + # 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, None, 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,37 @@ 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=include_derived) + if include_derived_resolved: + derived_fields = await get_derived_fields_for_surface( + db, + EntityType.filament, + None, + 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..6dc83171b 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=include_derived) + payload: list[Spool] = [] + # 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, None, 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,32 @@ 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=include_derived) + if include_derived_resolved: + 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( + 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..bdfdce0ae 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=include_derived) + payload: list[Vendor] = [] + # 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, None, 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,32 @@ 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=include_derived) + if include_derived_resolved: + 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( + 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..1ef9e0930 --- /dev/null +++ b/spoolman/derived_fields.py @@ -0,0 +1,899 @@ +"""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 +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, get_extra_fields +from spoolman.settings import parse_setting + +logger = logging.getLogger(__name__) + + +class DerivedFieldType(Enum): + """Supported output types for a derived field.""" + + number = "number" + text = "text" + boolean = "boolean" + date = "date" + datetime = "datetime" + time = "time" + + +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") + result_type: DerivedFieldType | None = Field(default=None, description="Expected result type for the preview") + + +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: + # 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 if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + if isinstance(value, date): + 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" + 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.") + + +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", +} + +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): + 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]: + """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 + 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], + *, + 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: + """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) + + +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: + # 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: + return json.loads(value) + except json.JSONDecodeError: + return value + + +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(): + 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) + + # 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"] + 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] + 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.""" + 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] + 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/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/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/test_derived.py b/tests_integration/tests/fields/test_derived.py new file mode 100644 index 000000000..975cf08c0 --- /dev/null +++ b/tests_integration/tests/fields/test_derived.py @@ -0,0 +1,113 @@ +"""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"] + + +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 new file mode 100644 index 000000000..b1f28bc34 --- /dev/null +++ b/tests_integration/tests/fields/test_derived_api.py @@ -0,0 +1,132 @@ +"""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, surfaces: list[str] | None = None) -> 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": surfaces or ["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(enabled=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(enabled=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(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)