diff --git a/homedocs/src/pages/changelog.mdx b/homedocs/src/pages/changelog.mdx index ee6da0477..a299197e5 100644 --- a/homedocs/src/pages/changelog.mdx +++ b/homedocs/src/pages/changelog.mdx @@ -4,6 +4,14 @@ title: Changelog description: Version history and release notes --- +## Unreleased + +**Features** + +- Added `validateOptionExists` flag to `LookupField`. When enabled and `options` is an array, the field reports a validation error (`invalidOptionText`) if the stored selection is not present in the options list. Useful for surfacing stale ids preserved across sessions ([#1271](https://github.com/codaxy/cxjs/issues/1271)) + +--- + ## cx\@26.5.1 **Features** diff --git a/homedocs/src/pages/docs/forms/lookup-field.mdx b/homedocs/src/pages/docs/forms/lookup-field.mdx index 55d358235..ecf9d1a7d 100644 --- a/homedocs/src/pages/docs/forms/lookup-field.mdx +++ b/homedocs/src/pages/docs/forms/lookup-field.mdx @@ -106,16 +106,18 @@ Use `fetchAll` to fetch all data once and filter client-side, which is more effi | Property | Type | Default | Description | | ---------------- | --------- | ------- | ----------------------------------------------------- | -| `closeOnSelect` | `boolean` | `true` | Close dropdown after selection | -| `autoOpen` | `boolean` | `false` | Open dropdown on focus | -| `quickSelectAll` | `boolean` | `false` | Allow Ctrl+A to select all visible options | -| `sort` | `boolean` | `false` | Sort dropdown options alphabetically | +| `closeOnSelect` | `boolean` | `true` | Close dropdown after selection | +| `autoOpen` | `boolean` | `false` | Open dropdown on focus | +| `quickSelectAll` | `boolean` | `false` | Allow Ctrl+A to select all visible options | +| `sort` | `boolean` | `false` | Sort dropdown options alphabetically | +| `validateOptionExists` | `boolean` | `false` | Report a validation error when the selected value is not present in `options` | ### Messages | Property | Type | Default | Description | | --------------------------- | -------- | ---------------------------------------- | --------------------------------- | -| `loadingText` | `string` | `"Loading..."` | Text shown while loading | -| `noResultsText` | `string` | `"No results found."` | Text when no options match | -| `queryErrorText` | `string` | `"Error occurred while querying..."` | Text on query error | -| `minQueryLengthMessageText` | `string` | `"Type in at least {0} character(s)."` | Text when query is too short | \ No newline at end of file +| `loadingText` | `string` | `"Loading..."` | Text shown while loading | +| `noResultsText` | `string` | `"No results found."` | Text when no options match | +| `queryErrorText` | `string` | `"Error occurred while querying..."` | Text on query error | +| `minQueryLengthMessageText` | `string` | `"Type in at least {0} character(s)."` | Text when query is too short | +| `invalidOptionText` | `string` | `"The selected option is no longer available."` | Error when `validateOptionExists` fails | \ No newline at end of file diff --git a/packages/cx/src/locale/de-de.ts b/packages/cx/src/locale/de-de.ts index 52aaadf2f..1c1a3f3b8 100644 --- a/packages/cx/src/locale/de-de.ts +++ b/packages/cx/src/locale/de-de.ts @@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Bei der Abfrage der gesuchten Daten ist ein Felhler aufgetreten.", noResultsText: "Keine Ergebnisse gefunden.", minQueryLengthMessageText: "Geben Sie mindestens {0} Zeichen ein.", + invalidOptionText: "Die ausgewählte Option ist nicht mehr verfügbar.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/locale/en-us.ts b/packages/cx/src/locale/en-us.ts index 3b4eb9745..9ac2dda46 100644 --- a/packages/cx/src/locale/en-us.ts +++ b/packages/cx/src/locale/en-us.ts @@ -15,6 +15,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Error occurred while querying for lookup data.", noResultsText: "No results found.", minQueryLengthMessageText: "Type in at least {0} character(s).", + invalidOptionText: "The selected option is no longer available.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/locale/es-es.ts b/packages/cx/src/locale/es-es.ts index 1091e9990..de1be43c7 100644 --- a/packages/cx/src/locale/es-es.ts +++ b/packages/cx/src/locale/es-es.ts @@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Se produjo un error al consultar los datos de búsqueda.", noResultsText: "No se han encontrado resultados.", minQueryLengthMessageText: "Escriba al menos {0} caracteres.", + invalidOptionText: "La opción seleccionada ya no está disponible.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/locale/fr-fr.ts b/packages/cx/src/locale/fr-fr.ts index 73746399b..bc874d25b 100644 --- a/packages/cx/src/locale/fr-fr.ts +++ b/packages/cx/src/locale/fr-fr.ts @@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Une erreur s'est produite lors de l'interrogation des données de recherche.", noResultsText: "Aucun résultat trouvé.", minQueryLengthMessageText: "Tapez au moins {0} caractère (s).", + invalidOptionText: "L'option sélectionnée n'est plus disponible.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/locale/nl-nl.ts b/packages/cx/src/locale/nl-nl.ts index 72c03f5a3..2718fb717 100644 --- a/packages/cx/src/locale/nl-nl.ts +++ b/packages/cx/src/locale/nl-nl.ts @@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Er is een fout opgetreden bij het weergeven van gegevens.", noResultsText: "Geen resultaten gevonden", minQueryLengthMessageText: "Voer minimaal {0} tekens in.", + invalidOptionText: "De geselecteerde optie is niet meer beschikbaar.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/locale/pt-pt.ts b/packages/cx/src/locale/pt-pt.ts index e0a481a43..1efad21df 100644 --- a/packages/cx/src/locale/pt-pt.ts +++ b/packages/cx/src/locale/pt-pt.ts @@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Ocorreu um erro ao consultar os dados de pesquisa.", noResultsText: "Nenhum resultado encontrado.", minQueryLengthMessageText: "Digite pelo menos {0} caractere(s).", + invalidOptionText: "A opção selecionada já não está disponível.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/locale/sr-latn-ba.ts b/packages/cx/src/locale/sr-latn-ba.ts index e578f4e1b..03299c890 100644 --- a/packages/cx/src/locale/sr-latn-ba.ts +++ b/packages/cx/src/locale/sr-latn-ba.ts @@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", { queryErrorText: "Došlo je do greške kod pribavljanja podataka za prikaz.", noResultsText: "Rezultati nisu pronađeni.", minQueryLengthMessageText: "Unesite najmanje {0} karakter(a).", + invalidOptionText: "Izabrana opcija više nije dostupna.", }); // In common for Calendar and MonthPicker diff --git a/packages/cx/src/widgets/form/LookupField.spec.tsx b/packages/cx/src/widgets/form/LookupField.spec.tsx index ddd481880..d2d8474f3 100644 --- a/packages/cx/src/widgets/form/LookupField.spec.tsx +++ b/packages/cx/src/widgets/form/LookupField.spec.tsx @@ -1,5 +1,10 @@ import { createAccessorModelProxy } from "../../data/createAccessorModelProxy"; import { LookupField } from "./LookupField"; +import { Store } from "../../data/Store"; +import { ValidationGroup } from "./ValidationGroup"; +import { bind } from "../../ui/bind"; +import { createTestRenderer } from "../../util/test/createTestRenderer"; +import assert from "assert"; interface User { id: number; @@ -90,4 +95,148 @@ describe("LookupField", () => { ); }); + + describe("validateOptionExists", () => { + const options = [ + { id: 1, text: "One" }, + { id: 2, text: "Two" }, + ]; + + it("reports an error when the selected value is missing from options", async () => { + let widget = ( + + + + + + ); + + let store = new Store(); + store.set("value", 99); + store.set("text", "Stale"); + + await createTestRenderer(store, widget); + + let errors = store.get("errors"); + assert.equal(errors.length, 1); + assert.equal(errors[0].message, "The selected option is no longer available."); + }); + + it("does not report an error when the selected value matches an option", async () => { + let widget = ( + + + + + + ); + + let store = new Store(); + store.set("value", 1); + store.set("text", "One"); + + await createTestRenderer(store, widget); + + let errors = store.get("errors"); + assert.equal(errors.length, 0); + }); + + it("does not report an error when the field is empty", async () => { + let widget = ( + + + + + + ); + + let store = new Store(); + + await createTestRenderer(store, widget); + + let errors = store.get("errors"); + assert.equal(errors.length, 0); + }); + + it("does not report an error when options are not provided (server-side mode)", async () => { + let widget = ( + + + []} + validateOptionExists + /> + + + ); + + let store = new Store(); + store.set("value", 99); + store.set("text", "Stale"); + + await createTestRenderer(store, widget); + + let errors = store.get("errors"); + assert.equal(errors.length, 0); + }); + + it("reports an error in multiple mode when some ids are not in options", async () => { + let widget = ( + + + + + + ); + + let store = new Store(); + store.set("values", [1, 99]); + + await createTestRenderer(store, widget); + + let errors = store.get("errors"); + assert.equal(errors.length, 1); + }); + + it("does not validate by default (back-compat)", async () => { + let widget = ( + + + + + + ); + + let store = new Store(); + store.set("value", 99); + store.set("text", "Stale"); + + await createTestRenderer(store, widget); + + let errors = store.get("errors"); + assert.equal(errors.length, 0); + }); + }); }); diff --git a/packages/cx/src/widgets/form/LookupField.tsx b/packages/cx/src/widgets/form/LookupField.tsx index e2c0dcdee..85113c93c 100644 --- a/packages/cx/src/widgets/form/LookupField.tsx +++ b/packages/cx/src/widgets/form/LookupField.tsx @@ -117,6 +117,12 @@ interface LookupFieldBaseConfig extends FieldConfig { /** Error message displayed if server query throws an exception. */ queryErrorText?: string; + /** Set to `true` to report a validation error when the selected value is not present in `options`. Only applies when `options` is an array. Default is `false`. */ + validateOptionExists?: boolean; + + /** Error message displayed when the selected value is not present in `options`. */ + invalidOptionText?: string; + /** Message to be displayed if no entries match the user query. */ noResultsText?: string; @@ -292,6 +298,8 @@ export class LookupField extends Field< declare public minOptionsForSearchField: number; declare public loadingText: string; declare public queryErrorText: string; + declare public validateOptionExists: boolean; + declare public invalidOptionText: string; declare public noResultsText: string; declare public optionIdField: string; declare public optionTextField: string; @@ -501,6 +509,22 @@ export class LookupField extends Field< (instance as DropdownInstance).lastDropdown = context.lastDropdown; + if ( + this.validateOptionExists && + isArray(data.options) && + !this.isEmpty(data) + ) { + let invalid = this.multiple + ? isArray(data.values) && data.records!.length < data.values.length + : !data.options.some(($option) => + areKeysEqual( + getOptionKey(this.keyBindings!, { $option }), + data.selectedKeys[0], + ), + ); + if (invalid) data.error = this.invalidOptionText; + } + super.prepareData(context, instance); } @@ -604,6 +628,9 @@ LookupField.prototype.minOptionsForSearchField = 7; LookupField.prototype.loadingText = "Loading..."; LookupField.prototype.queryErrorText = "Error occurred while querying for lookup data."; +LookupField.prototype.validateOptionExists = false; +LookupField.prototype.invalidOptionText = + "The selected option is no longer available."; LookupField.prototype.noResultsText = "No results found."; LookupField.prototype.optionIdField = "id"; LookupField.prototype.optionTextField = "text";