@olenbetong/appframe-ds 1.9.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/package.json +3 -3
- package/src/AfLookup/Lookup.tsx +11 -1
- package/src/filter/FilterEditor.css +14 -0
- package/src/filter/FilterEditor.test.tsx +150 -2
- package/src/filter/FilterRow.tsx +14 -1
- package/src/filter/FilterValueEditor.tsx +235 -74
- package/src/layout/AppMenuItem.css +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @olenbetong/appframe-ds
|
|
2
2
|
|
|
3
|
+
## 1.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3aea329: The filter can compare a field with another column of the same type, offers the dates Now, Day after tomorrow and Two weeks away, and lets each end of a range be typed or picked from the list. The data API cannot read such a filter object, so data handlers convert it through `api/filter/convert` and send it as a filter string (`needsFilterString`).
|
|
8
|
+
- 05f69f0: Date fields in the filter get the whole-period checks of the Windows filter editor, such as Is last week, Is today and Is earlier this month. They take no value, so the value field is hidden, and a filter using one is sent as a filter string.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- a0fc1ea: AppMenuItem: a long label wraps onto a second line instead of being cut off with an ellipsis.
|
|
13
|
+
- b1cd401: The filter editor groups the relative week, month and year values under a "First day of" heading, as the Windows date chooser does, so "Last week" is not read as the whole week. The toggle next to the value shows a list icon while a relative value is selected.
|
|
14
|
+
- Updated dependencies [3aea329]
|
|
15
|
+
- Updated dependencies [05f69f0]
|
|
16
|
+
- @olenbetong/appframe-core@2.16.0
|
|
17
|
+
- @olenbetong/appframe-data@1.9.0
|
|
18
|
+
|
|
19
|
+
## 1.9.3
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- aae787f: AfLookup: `slotProps.dialog` reaches the lookup dialog. It was accepted but dropped, so a dialog `className` or `style` (such as a wider `--default-width` for a grid lookup) had no effect.
|
|
24
|
+
|
|
3
25
|
## 1.9.2
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-ds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Components that bind Designsystemet components to Appframe data objects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./es/index.d.ts",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
"@atlaskit/pragmatic-drag-and-drop": "4.0.0",
|
|
64
64
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "3.0.0",
|
|
65
65
|
"@base-ui/react": "1.9.0",
|
|
66
|
-
"@olenbetong/appframe-core": "^2.
|
|
67
|
-
"@olenbetong/appframe-data": "^1.
|
|
66
|
+
"@olenbetong/appframe-core": "^2.16.0",
|
|
67
|
+
"@olenbetong/appframe-data": "^1.9.0",
|
|
68
68
|
"@olenbetong/appframe-react": "^2.2.8",
|
|
69
69
|
"clsx": "^2.1.1",
|
|
70
70
|
"dockview-react": "8.6.1",
|
package/src/AfLookup/Lookup.tsx
CHANGED
|
@@ -144,7 +144,17 @@ export function AfLookup<T extends Record<string, unknown>>({
|
|
|
144
144
|
value,
|
|
145
145
|
...props
|
|
146
146
|
}: AfLookupProps<T>) {
|
|
147
|
-
let {
|
|
147
|
+
let {
|
|
148
|
+
inputId,
|
|
149
|
+
open: _open,
|
|
150
|
+
dialog,
|
|
151
|
+
anchorName,
|
|
152
|
+
openDialog,
|
|
153
|
+
onKeyDown,
|
|
154
|
+
} = useLookup<T>({
|
|
155
|
+
...props,
|
|
156
|
+
slotProps: { dialog: slotProps?.dialog },
|
|
157
|
+
});
|
|
148
158
|
|
|
149
159
|
let hasClear = !!(props.nullable && value && props.onChange);
|
|
150
160
|
|
|
@@ -136,6 +136,14 @@
|
|
|
136
136
|
min-width: 8rem;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/*
|
|
140
|
+
* An operator without a value, such as "Is last week", takes the value's place,
|
|
141
|
+
* so the field keeps the width it has in the other rows.
|
|
142
|
+
*/
|
|
143
|
+
.ObFilterEditor-row:not(:has(.ObFilterEditor-valueCell)) .ObFilterEditor-operator {
|
|
144
|
+
flex: 2 1 calc(26rem + var(--ds-size-2));
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
.ObFilterEditor-valueCell {
|
|
140
148
|
display: flex;
|
|
141
149
|
align-items: center;
|
|
@@ -157,6 +165,12 @@
|
|
|
157
165
|
min-width: 0;
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
/* Each end of a range carries its own list toggle. */
|
|
169
|
+
.ObFilterEditor-pair > .ObFilterEditor-valueCell {
|
|
170
|
+
flex: 1 1 0;
|
|
171
|
+
min-width: 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
.ObFilterEditor-pairSeparator {
|
|
161
175
|
flex: 0 0 auto;
|
|
162
176
|
color: var(--ds-color-neutral-text-subtle);
|
|
@@ -126,11 +126,29 @@ describe("FilterEditor", () => {
|
|
|
126
126
|
<FilterEditor fields={FIELDS} value={filterWith("Created", "equals", "", "datetime")} onChange={onChange} />,
|
|
127
127
|
);
|
|
128
128
|
|
|
129
|
-
await user.click(screen.getByRole("button", { name: "
|
|
129
|
+
await user.click(screen.getByRole("button", { name: "Choose from a list" }));
|
|
130
130
|
|
|
131
131
|
expect(firstExpression(onChange)).toMatchObject({ value: "Yesterday", valueType: "special" });
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
it("groups the week, month and year values under First day of", () => {
|
|
135
|
+
render(<FilterEditor fields={FIELDS} value={filterWith("Created", "equals", "LastWeek", "special")} />);
|
|
136
|
+
|
|
137
|
+
let group = screen.getByRole("group", { name: "First day of" });
|
|
138
|
+
let grouped = Array.from(group.querySelectorAll("option"), (option) => option.value);
|
|
139
|
+
|
|
140
|
+
expect(grouped).toContain("special:LastWeek");
|
|
141
|
+
expect(grouped).toContain("special:ThisMonth");
|
|
142
|
+
expect(grouped).not.toContain("special:Today");
|
|
143
|
+
expect(screen.getByRole("option", { name: "Today" }).parentElement).not.toBe(group);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not group the current user value", () => {
|
|
147
|
+
render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "equals", "CurrentUser", "special")} />);
|
|
148
|
+
|
|
149
|
+
expect(screen.queryByRole("group", { name: "First day of" })).not.toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
134
152
|
it("adds the seconds a datetime input leaves out", () => {
|
|
135
153
|
let onChange = vi.fn();
|
|
136
154
|
render(
|
|
@@ -160,7 +178,7 @@ describe("FilterEditor", () => {
|
|
|
160
178
|
it("does not offer relative values for numbers", () => {
|
|
161
179
|
render(<FilterEditor fields={FIELDS} value={filterWith("Age", "equals", "1", "number")} />);
|
|
162
180
|
|
|
163
|
-
expect(screen.queryByRole("button", { name: "
|
|
181
|
+
expect(screen.queryByRole("button", { name: "Choose from a list" })).not.toBeInTheDocument();
|
|
164
182
|
});
|
|
165
183
|
|
|
166
184
|
it("removes a condition", async () => {
|
|
@@ -283,3 +301,133 @@ describe("FilterEditor", () => {
|
|
|
283
301
|
expect(screen.getByLabelText("Value")).toHaveValue("gold");
|
|
284
302
|
});
|
|
285
303
|
});
|
|
304
|
+
|
|
305
|
+
describe("FilterEditor value list", () => {
|
|
306
|
+
const DATE_FIELDS: FieldMetadata[] = [
|
|
307
|
+
{ name: "DeliveryDate", caption: "Delivery date", type: "datetime" },
|
|
308
|
+
{ name: "Date", caption: "Date", type: "date" },
|
|
309
|
+
{ name: "Amount", caption: "Amount", type: "number" },
|
|
310
|
+
{ name: "Quantity", caption: "Quantity", type: "number" },
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
it("offers the other columns of the same type", () => {
|
|
314
|
+
render(<FilterEditor fields={DATE_FIELDS} value={filterWith("DeliveryDate", "greaterthan", "Date", "column")} />);
|
|
315
|
+
|
|
316
|
+
let group = screen.getByRole("group", { name: "Columns" });
|
|
317
|
+
let columns = Array.from(group.querySelectorAll("option"), (option) => option.textContent);
|
|
318
|
+
|
|
319
|
+
expect(columns).toEqual(["Date"]);
|
|
320
|
+
expect(screen.getByLabelText("Value")).toHaveValue("column:Date");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("offers the dates only the Windows editor had", () => {
|
|
324
|
+
render(<FilterEditor fields={DATE_FIELDS} value={filterWith("DeliveryDate", "equals", "Now", "special")} />);
|
|
325
|
+
|
|
326
|
+
expect(screen.getByRole("option", { name: "Now" })).toBeInTheDocument();
|
|
327
|
+
expect(screen.getByRole("option", { name: "Day after tomorrow" })).toBeInTheDocument();
|
|
328
|
+
expect(screen.getByRole("option", { name: "Two weeks away" })).toBeInTheDocument();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("picks a column as the value", () => {
|
|
332
|
+
let onChange = vi.fn();
|
|
333
|
+
render(
|
|
334
|
+
<FilterEditor
|
|
335
|
+
fields={DATE_FIELDS}
|
|
336
|
+
value={filterWith("DeliveryDate", "greaterthan", "Today", "special")}
|
|
337
|
+
onChange={onChange}
|
|
338
|
+
/>,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "column:Date" } });
|
|
342
|
+
|
|
343
|
+
expect(firstExpression(onChange)).toMatchObject({ value: "Date", valueType: "column" });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("lets a number field pick another number column", async () => {
|
|
347
|
+
let user = userEvent.setup();
|
|
348
|
+
let onChange = vi.fn();
|
|
349
|
+
render(
|
|
350
|
+
<FilterEditor fields={DATE_FIELDS} value={filterWith("Amount", "lessthan", "", "number")} onChange={onChange} />,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await user.click(screen.getByRole("button", { name: "Choose from a list" }));
|
|
354
|
+
|
|
355
|
+
expect(firstExpression(onChange)).toMatchObject({ value: "Quantity", valueType: "column" });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("switches one end of a range to the list", async () => {
|
|
359
|
+
let user = userEvent.setup();
|
|
360
|
+
let onChange = vi.fn();
|
|
361
|
+
render(
|
|
362
|
+
<FilterEditor
|
|
363
|
+
fields={DATE_FIELDS}
|
|
364
|
+
value={filterWith("DeliveryDate", "between", ["", "2026-11-30T00:00:00"], "datetime")}
|
|
365
|
+
onChange={onChange}
|
|
366
|
+
/>,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
let [fromToggle] = screen.getAllByRole("button", { name: "Choose from a list" });
|
|
370
|
+
await user.click(fromToggle!);
|
|
371
|
+
|
|
372
|
+
expect(firstExpression(onChange)).toMatchObject({
|
|
373
|
+
value: [{ valueType: "special", value: "Yesterday" }, "2026-11-30T00:00:00"],
|
|
374
|
+
valueType: "datetime",
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("shows a list choice at each end of a range", () => {
|
|
379
|
+
render(
|
|
380
|
+
<FilterEditor
|
|
381
|
+
fields={DATE_FIELDS}
|
|
382
|
+
value={filterWith(
|
|
383
|
+
"DeliveryDate",
|
|
384
|
+
"between",
|
|
385
|
+
[
|
|
386
|
+
{ valueType: "special", value: "Tomorrow" },
|
|
387
|
+
{ valueType: "column", value: "Date" },
|
|
388
|
+
],
|
|
389
|
+
"datetime",
|
|
390
|
+
)}
|
|
391
|
+
/>,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(screen.getByLabelText("From")).toHaveValue("special:Tomorrow");
|
|
395
|
+
expect(screen.getByLabelText("To")).toHaveValue("column:Date");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("hides the value for a whole-period check", async () => {
|
|
399
|
+
let user = userEvent.setup();
|
|
400
|
+
let onChange = vi.fn();
|
|
401
|
+
render(
|
|
402
|
+
<FilterEditor
|
|
403
|
+
fields={DATE_FIELDS}
|
|
404
|
+
value={filterWith("DeliveryDate", "equals", "", "datetime")}
|
|
405
|
+
onChange={onChange}
|
|
406
|
+
/>,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
await user.selectOptions(screen.getByLabelText("Operator"), "islastweek");
|
|
410
|
+
|
|
411
|
+
expect(firstExpression(onChange)).toMatchObject({ operator: "islastweek", value: null, valueType: "null" });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("shows no value field for a whole-period check", () => {
|
|
415
|
+
render(<FilterEditor fields={DATE_FIELDS} value={filterWith("DeliveryDate", "islastweek", null, "null")} />);
|
|
416
|
+
|
|
417
|
+
expect(screen.getByLabelText("Operator")).toHaveValue("islastweek");
|
|
418
|
+
expect(screen.queryByLabelText("Value")).not.toBeInTheDocument();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("does not offer columns for text checks", () => {
|
|
422
|
+
render(
|
|
423
|
+
<FilterEditor
|
|
424
|
+
fields={[...DATE_FIELDS, { name: "Name", type: "string" }, { name: "Other", type: "string" }]}
|
|
425
|
+
value={filterWith("Name", "contains", "a")}
|
|
426
|
+
/>,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
expect(screen.queryByRole("group", { name: "Columns" })).not.toBeInTheDocument();
|
|
430
|
+
// The current user can still be picked.
|
|
431
|
+
expect(screen.getByRole("button", { name: "Choose from a list" })).toBeInTheDocument();
|
|
432
|
+
});
|
|
433
|
+
});
|
package/src/filter/FilterRow.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type FilterOperator,
|
|
9
9
|
getOperatorLabel,
|
|
10
10
|
getOperatorsForField,
|
|
11
|
+
isRangeEnd,
|
|
11
12
|
normalizeOperator,
|
|
12
13
|
} from "@olenbetong/appframe-core";
|
|
13
14
|
import { Combobox } from "../autocomplete/index.js";
|
|
@@ -16,6 +17,14 @@ import { FilterValueEditor } from "./FilterValueEditor.js";
|
|
|
16
17
|
import type { FilterEditorSize, GetFieldOptions } from "./types.js";
|
|
17
18
|
import { getLocalizedString } from "../i18n.js";
|
|
18
19
|
|
|
20
|
+
function holdsColumn(expression: FilterExpression): boolean {
|
|
21
|
+
if (expression.valueType === "column") return true;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
Array.isArray(expression.value) && expression.value.some((item) => isRangeEnd(item) && item.valueType === "column")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
export function getFieldLabel(field: FieldMetadata) {
|
|
20
29
|
return field.caption || field.name;
|
|
21
30
|
}
|
|
@@ -60,7 +69,10 @@ export function FilterRow({
|
|
|
60
69
|
let keep = operators.includes(operator) ? operator : undefined;
|
|
61
70
|
let created = createExpression(next, keep);
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
// A column picked for the old field may not suit the new one.
|
|
73
|
+
let keepValue = keep && !holdsColumn(expression);
|
|
74
|
+
|
|
75
|
+
onChange(keepValue ? { ...created, value: expression.value, valueType: expression.valueType } : created);
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
return (
|
|
@@ -96,6 +108,7 @@ export function FilterRow({
|
|
|
96
108
|
<FilterValueEditor
|
|
97
109
|
expression={expression}
|
|
98
110
|
field={effectiveField}
|
|
111
|
+
fields={fields}
|
|
99
112
|
options={field ? getFieldOptions?.(field) : undefined}
|
|
100
113
|
onChange={onChange}
|
|
101
114
|
readOnly={readOnly}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { Select, Textfield } from "@digdir/designsystemet-react";
|
|
2
|
-
import { PencilIcon } from "@navikt/aksel-icons";
|
|
2
|
+
import { PencilIcon, TasklistIcon } from "@navikt/aksel-icons";
|
|
3
3
|
import {
|
|
4
4
|
type DynamicValue,
|
|
5
5
|
type FieldMetadata,
|
|
6
6
|
type FilterExpression,
|
|
7
7
|
type FilterOperator,
|
|
8
|
+
type FilterRangeEnd,
|
|
9
|
+
getColumnsForField,
|
|
8
10
|
getDynamicValueLabel,
|
|
9
11
|
getDynamicValuesForField,
|
|
10
12
|
getOperatorArity,
|
|
11
13
|
getValueType,
|
|
14
|
+
isRangeEnd,
|
|
12
15
|
normalizeOperator,
|
|
16
|
+
supportsColumnValue,
|
|
13
17
|
} from "@olenbetong/appframe-core";
|
|
14
18
|
import { Combobox } from "../autocomplete/index.js";
|
|
15
19
|
|
|
@@ -20,6 +24,8 @@ import { getLocalizedString } from "../i18n.js";
|
|
|
20
24
|
export type FilterValueEditorProps = {
|
|
21
25
|
expression: FilterExpression;
|
|
22
26
|
field?: FieldMetadata;
|
|
27
|
+
/** All fields of the view, offered as values to compare with. */
|
|
28
|
+
fields?: FieldMetadata[];
|
|
23
29
|
options?: FilterValueOption[];
|
|
24
30
|
onChange: (expression: FilterExpression) => void;
|
|
25
31
|
readOnly?: boolean;
|
|
@@ -40,7 +46,7 @@ function getInputType(field: FieldMetadata | undefined): "text" | "number" | "da
|
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
function toStringValue(value:
|
|
49
|
+
function toStringValue(value: unknown): string {
|
|
44
50
|
if (value === null || value === undefined) return "";
|
|
45
51
|
if (value instanceof Date) return value.toISOString();
|
|
46
52
|
|
|
@@ -66,22 +72,151 @@ function toFilterValue(value: FilterExpression["value"], inputType: string): Fil
|
|
|
66
72
|
return value;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
/** Relative dates that stand for a single day or moment. */
|
|
76
|
+
const DAY_VALUES = new Set<string>([
|
|
77
|
+
"Yesterday",
|
|
78
|
+
"Today",
|
|
79
|
+
"Now",
|
|
80
|
+
"Tomorrow",
|
|
81
|
+
"DayAfterTomorrow",
|
|
82
|
+
"TwoWeeksAway",
|
|
83
|
+
"UserYesterday",
|
|
84
|
+
"UserToday",
|
|
85
|
+
"UserTomorrow",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Splits a date field's relative values into single days and the rest, which
|
|
90
|
+
* resolve to the first day of a week, month or year. Those are grouped under a
|
|
91
|
+
* "First day of" heading, as in the Windows date chooser, so "Last week" is
|
|
92
|
+
* not read as the whole week.
|
|
93
|
+
*/
|
|
94
|
+
function groupDynamicValues(values: string[], field: FieldMetadata | undefined) {
|
|
95
|
+
let isDate = field?.type === "date" || field?.type === "datetime";
|
|
96
|
+
if (!isDate) return { ungrouped: values, periodStarts: [] };
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
ungrouped: values.filter((value) => DAY_VALUES.has(value)),
|
|
100
|
+
periodStarts: values.filter((value) => !DAY_VALUES.has(value)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toArrayValue(value: FilterExpression["value"]): string[] {
|
|
105
|
+
return Array.isArray(value) ? value.map(toStringValue) : [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A range end as the editor works with it: a typed value or a list choice. */
|
|
109
|
+
type RangeEndValue = string | FilterRangeEnd;
|
|
110
|
+
|
|
111
|
+
function toRangeEnds(value: FilterExpression["value"]): [RangeEndValue, RangeEndValue] {
|
|
112
|
+
let array: unknown[] = Array.isArray(value) ? value : [];
|
|
113
|
+
let toEnd = (item: unknown): RangeEndValue => (isRangeEnd(item) ? item : toStringValue(item));
|
|
72
114
|
|
|
73
115
|
// Pair editors always render two inputs, even before both are filled in.
|
|
74
|
-
return
|
|
116
|
+
return [toEnd(array[0]), toEnd(array[1])];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Encodes a list choice as a single `<option>` value. */
|
|
120
|
+
function toOptionValue(end: FilterRangeEnd): string {
|
|
121
|
+
return `${end.valueType}:${end.value ?? ""}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fromOptionValue(optionValue: string): FilterRangeEnd {
|
|
125
|
+
let separator = optionValue.indexOf(":");
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
valueType: optionValue.slice(0, separator) as FilterRangeEnd["valueType"],
|
|
129
|
+
value: optionValue.slice(separator + 1),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type ListChoicesProps = {
|
|
134
|
+
"aria-label": string;
|
|
135
|
+
columns: FieldMetadata[];
|
|
136
|
+
dynamicValues: string[];
|
|
137
|
+
field?: FieldMetadata;
|
|
138
|
+
readOnly?: boolean;
|
|
139
|
+
size?: FilterEditorSize;
|
|
140
|
+
value: FilterRangeEnd;
|
|
141
|
+
onChange: (value: FilterRangeEnd) => void;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The list a value can be picked from instead of typed: the other columns of
|
|
146
|
+
* the same type and the relative values, in the order of the Windows editor.
|
|
147
|
+
*/
|
|
148
|
+
function ListChoices({
|
|
149
|
+
"aria-label": ariaLabel,
|
|
150
|
+
columns,
|
|
151
|
+
dynamicValues,
|
|
152
|
+
field,
|
|
153
|
+
readOnly,
|
|
154
|
+
size,
|
|
155
|
+
value,
|
|
156
|
+
onChange,
|
|
157
|
+
}: ListChoicesProps) {
|
|
158
|
+
let { ungrouped, periodStarts } = groupDynamicValues(dynamicValues, field);
|
|
159
|
+
let renderDynamic = (dynamicValue: string) => (
|
|
160
|
+
<Select.Option key={dynamicValue} value={toOptionValue({ valueType: "special", value: dynamicValue })}>
|
|
161
|
+
{getDynamicValueLabel(dynamicValue as DynamicValue)}
|
|
162
|
+
</Select.Option>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<Select
|
|
167
|
+
className="ObFilterEditor-value"
|
|
168
|
+
aria-label={ariaLabel}
|
|
169
|
+
data-size={size}
|
|
170
|
+
disabled={readOnly}
|
|
171
|
+
value={toOptionValue(value)}
|
|
172
|
+
onChange={(event) => onChange(fromOptionValue(event.target.value))}
|
|
173
|
+
>
|
|
174
|
+
{columns.length > 0 && (
|
|
175
|
+
<Select.Optgroup label={getLocalizedString("Columns")}>
|
|
176
|
+
{columns.map((column) => (
|
|
177
|
+
<Select.Option key={column.name} value={toOptionValue({ valueType: "column", value: column.name })}>
|
|
178
|
+
{column.caption || column.name}
|
|
179
|
+
</Select.Option>
|
|
180
|
+
))}
|
|
181
|
+
</Select.Optgroup>
|
|
182
|
+
)}
|
|
183
|
+
{ungrouped.map(renderDynamic)}
|
|
184
|
+
{periodStarts.length > 0 && (
|
|
185
|
+
<Select.Optgroup label={getLocalizedString("First day of")}>{periodStarts.map(renderDynamic)}</Select.Optgroup>
|
|
186
|
+
)}
|
|
187
|
+
</Select>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Switches a value between typing it and picking it from the list. */
|
|
192
|
+
function ListToggle({ isList, onToggle }: { isList: boolean; onToggle: () => void }) {
|
|
193
|
+
let label = getLocalizedString("Choose from a list");
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
className="ObFilterEditor-iconButton"
|
|
199
|
+
aria-pressed={isList}
|
|
200
|
+
title={label}
|
|
201
|
+
aria-label={label}
|
|
202
|
+
onClick={onToggle}
|
|
203
|
+
>
|
|
204
|
+
{/* Shows the current mode, like the Windows editor's toggle. */}
|
|
205
|
+
{isList ? <TasklistIcon aria-hidden /> : <PencilIcon aria-hidden />}
|
|
206
|
+
</button>
|
|
207
|
+
);
|
|
75
208
|
}
|
|
76
209
|
|
|
77
210
|
/**
|
|
78
211
|
* The value part of a filter row. Which editor is rendered depends on the
|
|
79
|
-
* operator's arity, the field's type, and whether the value is
|
|
80
|
-
* ("special")
|
|
212
|
+
* operator's arity, the field's type, and whether the value is picked from the
|
|
213
|
+
* list of other columns and relative ("special") values such as `Today` or
|
|
214
|
+
* `CurrentUser`. Each end of a range can be typed or picked on its own.
|
|
81
215
|
*/
|
|
82
216
|
export function FilterValueEditor({
|
|
83
217
|
expression,
|
|
84
218
|
field,
|
|
219
|
+
fields,
|
|
85
220
|
options,
|
|
86
221
|
onChange,
|
|
87
222
|
readOnly,
|
|
@@ -89,46 +224,111 @@ export function FilterValueEditor({
|
|
|
89
224
|
}: FilterValueEditorProps) {
|
|
90
225
|
let operator = normalizeOperator(expression.operator);
|
|
91
226
|
let arity = getOperatorArity(operator);
|
|
92
|
-
let
|
|
227
|
+
let isList = expression.valueType === "special" || expression.valueType === "column";
|
|
93
228
|
let dynamicValues = field ? getDynamicValuesForField(field) : [];
|
|
229
|
+
let columns = field && fields && supportsColumnValue(operator) ? getColumnsForField(field, fields) : [];
|
|
94
230
|
let inputType = getInputType(field);
|
|
231
|
+
let typedValueType = field ? getValueType(field, operator as FilterOperator) : "string";
|
|
232
|
+
let canPickFromList = !readOnly && (dynamicValues.length > 0 || columns.length > 0);
|
|
233
|
+
|
|
234
|
+
/** The first entry of the list, used when switching a value to it. */
|
|
235
|
+
let firstChoice: FilterRangeEnd =
|
|
236
|
+
columns.length > 0 && dynamicValues.length === 0
|
|
237
|
+
? { valueType: "column", value: columns[0]!.name }
|
|
238
|
+
: { valueType: "special", value: dynamicValues[0] ?? null };
|
|
95
239
|
|
|
96
240
|
function setValue(value: FilterExpression["value"]) {
|
|
97
|
-
onChange({ ...expression, value:
|
|
241
|
+
onChange({ ...expression, value: isList ? value : toFilterValue(value, inputType) });
|
|
98
242
|
}
|
|
99
243
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
244
|
+
function setChoice(choice: FilterRangeEnd) {
|
|
245
|
+
onChange({ ...expression, value: choice.value, valueType: choice.valueType });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function toggleList() {
|
|
249
|
+
if (isList) {
|
|
250
|
+
onChange({ ...expression, value: "", valueType: typedValueType });
|
|
103
251
|
} else {
|
|
104
|
-
|
|
105
|
-
...expression,
|
|
106
|
-
value: arity === "pair" ? ["", ""] : arity === "list" ? [] : "",
|
|
107
|
-
valueType: field ? getValueType(field, operator as FilterOperator) : "string",
|
|
108
|
-
});
|
|
252
|
+
setChoice(firstChoice);
|
|
109
253
|
}
|
|
110
254
|
}
|
|
111
255
|
|
|
112
256
|
if (arity === "none") return null;
|
|
113
257
|
|
|
258
|
+
if (arity === "pair") {
|
|
259
|
+
let ends = toRangeEnds(expression.value);
|
|
260
|
+
|
|
261
|
+
function setEnd(index: 0 | 1, end: RangeEndValue) {
|
|
262
|
+
let next = [...ends];
|
|
263
|
+
next[index] = typeof end === "string" && inputType === "datetime-local" ? withSeconds(end) : end;
|
|
264
|
+
|
|
265
|
+
// Plain ends on both sides are sent the way the framework reads them.
|
|
266
|
+
let value = next.some(isRangeEnd) ? next : next.map((item) => toStringValue(item));
|
|
267
|
+
onChange({ ...expression, value, valueType: typedValueType });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let renderEnd = (index: 0 | 1, label: string) => {
|
|
271
|
+
let end = ends[index];
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div className="ObFilterEditor-valueCell">
|
|
275
|
+
{isRangeEnd(end) ? (
|
|
276
|
+
<ListChoices
|
|
277
|
+
aria-label={label}
|
|
278
|
+
columns={columns}
|
|
279
|
+
dynamicValues={dynamicValues}
|
|
280
|
+
field={field}
|
|
281
|
+
readOnly={readOnly}
|
|
282
|
+
size={size}
|
|
283
|
+
value={end}
|
|
284
|
+
onChange={(choice) => setEnd(index, choice)}
|
|
285
|
+
/>
|
|
286
|
+
) : (
|
|
287
|
+
<Textfield
|
|
288
|
+
className="ObFilterEditor-value"
|
|
289
|
+
aria-label={label}
|
|
290
|
+
data-size={size}
|
|
291
|
+
readOnly={readOnly}
|
|
292
|
+
type={inputType}
|
|
293
|
+
value={end}
|
|
294
|
+
onChange={(event) => setEnd(index, event.target.value)}
|
|
295
|
+
/>
|
|
296
|
+
)}
|
|
297
|
+
{canPickFromList && (
|
|
298
|
+
<ListToggle isList={isRangeEnd(end)} onToggle={() => setEnd(index, isRangeEnd(end) ? "" : firstChoice)} />
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className="ObFilterEditor-valueCell">
|
|
306
|
+
<div className="ObFilterEditor-pair">
|
|
307
|
+
{renderEnd(0, getLocalizedString("From"))}
|
|
308
|
+
<span className="ObFilterEditor-pairSeparator">{getLocalizedString("and")}</span>
|
|
309
|
+
{renderEnd(1, getLocalizedString("To"))}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
114
315
|
let editor: React.ReactNode;
|
|
115
316
|
|
|
116
|
-
if (
|
|
317
|
+
if (isList) {
|
|
117
318
|
editor = (
|
|
118
|
-
<
|
|
119
|
-
className="ObFilterEditor-value"
|
|
319
|
+
<ListChoices
|
|
120
320
|
aria-label={getLocalizedString("Value")}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
321
|
+
columns={columns}
|
|
322
|
+
dynamicValues={dynamicValues}
|
|
323
|
+
field={field}
|
|
324
|
+
readOnly={readOnly}
|
|
325
|
+
size={size}
|
|
326
|
+
value={{
|
|
327
|
+
valueType: expression.valueType as FilterRangeEnd["valueType"],
|
|
328
|
+
value: toStringValue(expression.value),
|
|
329
|
+
}}
|
|
330
|
+
onChange={setChoice}
|
|
331
|
+
/>
|
|
132
332
|
);
|
|
133
333
|
} else if (arity === "list") {
|
|
134
334
|
editor = options ? (
|
|
@@ -157,32 +357,6 @@ export function FilterValueEditor({
|
|
|
157
357
|
onChange={setValue}
|
|
158
358
|
/>
|
|
159
359
|
);
|
|
160
|
-
} else if (arity === "pair") {
|
|
161
|
-
let [from, to] = toArrayValue(expression.value, 2);
|
|
162
|
-
|
|
163
|
-
editor = (
|
|
164
|
-
<div className="ObFilterEditor-pair">
|
|
165
|
-
<Textfield
|
|
166
|
-
className="ObFilterEditor-value"
|
|
167
|
-
aria-label={getLocalizedString("From")}
|
|
168
|
-
data-size={size}
|
|
169
|
-
readOnly={readOnly}
|
|
170
|
-
type={inputType}
|
|
171
|
-
value={from}
|
|
172
|
-
onChange={(event) => setValue([event.target.value, to])}
|
|
173
|
-
/>
|
|
174
|
-
<span className="ObFilterEditor-pairSeparator">{getLocalizedString("and")}</span>
|
|
175
|
-
<Textfield
|
|
176
|
-
className="ObFilterEditor-value"
|
|
177
|
-
aria-label={getLocalizedString("To")}
|
|
178
|
-
data-size={size}
|
|
179
|
-
readOnly={readOnly}
|
|
180
|
-
type={inputType}
|
|
181
|
-
value={to}
|
|
182
|
-
onChange={(event) => setValue([from, event.target.value])}
|
|
183
|
-
/>
|
|
184
|
-
</div>
|
|
185
|
-
);
|
|
186
360
|
} else if (options) {
|
|
187
361
|
let current = toStringValue(expression.value);
|
|
188
362
|
|
|
@@ -214,26 +388,13 @@ export function FilterValueEditor({
|
|
|
214
388
|
);
|
|
215
389
|
}
|
|
216
390
|
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
// not offer either.
|
|
220
|
-
let canToggleDynamic = !readOnly && dynamicValues.length > 0 && arity === "single";
|
|
391
|
+
// A list of values is matched as a whole, so it has nothing to pick from.
|
|
392
|
+
let canToggle = canPickFromList && arity === "single";
|
|
221
393
|
|
|
222
394
|
return (
|
|
223
395
|
<div className="ObFilterEditor-valueCell">
|
|
224
396
|
{editor}
|
|
225
|
-
{
|
|
226
|
-
<button
|
|
227
|
-
type="button"
|
|
228
|
-
className="ObFilterEditor-iconButton"
|
|
229
|
-
aria-pressed={isDynamic}
|
|
230
|
-
title={getLocalizedString("Use a relative value")}
|
|
231
|
-
aria-label={getLocalizedString("Use a relative value")}
|
|
232
|
-
onClick={() => setDynamic(!isDynamic)}
|
|
233
|
-
>
|
|
234
|
-
<PencilIcon aria-hidden />
|
|
235
|
-
</button>
|
|
236
|
-
)}
|
|
397
|
+
{canToggle && <ListToggle isList={isList} onToggle={toggleList} />}
|
|
237
398
|
</div>
|
|
238
399
|
);
|
|
239
400
|
}
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
font-weight: 500;
|
|
38
38
|
color: var(--ds-color-neutral-text-default, rgba(0, 0, 0, 0.87));
|
|
39
39
|
min-width: 0;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/* Long labels wrap onto a second line, evened out, instead of being cut off */
|
|
41
|
+
overflow-wrap: anywhere;
|
|
42
|
+
text-wrap: balance;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
.AppMenuItem-info {
|