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";