Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions homedocs/src/pages/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
18 changes: 10 additions & 8 deletions homedocs/src/pages/docs/forms/lookup-field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| `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 |
1 change: 1 addition & 0 deletions packages/cx/src/locale/de-de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cx/src/locale/en-us.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cx/src/locale/es-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cx/src/locale/fr-fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cx/src/locale/nl-nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cx/src/locale/pt-pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cx/src/locale/sr-latn-ba.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions packages/cx/src/widgets/form/LookupField.spec.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -90,4 +95,148 @@ describe("LookupField", () => {
</cx>
);
});

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 = (
<cx>
<ValidationGroup errors={bind("errors")}>
<LookupField
value={bind("value")}
text={bind("text")}
options={options}
validateOptionExists
/>
</ValidationGroup>
</cx>
);

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 = (
<cx>
<ValidationGroup errors={bind("errors")}>
<LookupField
value={bind("value")}
text={bind("text")}
options={options}
validateOptionExists
/>
</ValidationGroup>
</cx>
);

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 = (
<cx>
<ValidationGroup errors={bind("errors")}>
<LookupField
value={bind("value")}
text={bind("text")}
options={options}
validateOptionExists
/>
</ValidationGroup>
</cx>
);

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 = (
<cx>
<ValidationGroup errors={bind("errors")}>
<LookupField
value={bind("value")}
text={bind("text")}
onQuery={() => []}
validateOptionExists
/>
</ValidationGroup>
</cx>
);

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 = (
<cx>
<ValidationGroup errors={bind("errors")}>
<LookupField
multiple
values={bind("values")}
options={options}
validateOptionExists
/>
</ValidationGroup>
</cx>
);

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 = (
<cx>
<ValidationGroup errors={bind("errors")}>
<LookupField value={bind("value")} text={bind("text")} options={options} />
</ValidationGroup>
</cx>
);

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);
});
});
});
27 changes: 27 additions & 0 deletions packages/cx/src/widgets/form/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ interface LookupFieldBaseConfig<TOption = any> 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;

Expand Down Expand Up @@ -292,6 +298,8 @@ export class LookupField<TOption = any, TRecord = any> 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;
Expand Down Expand Up @@ -501,6 +509,22 @@ export class LookupField<TOption = any, TRecord = any> 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);
}

Expand Down Expand Up @@ -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";
Expand Down
Loading