@object-ui/components 17.3.0 → 17.6.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 +2732 -1
- package/README.md +38 -38
- package/dist/custom/button-group.d.ts +1 -1
- package/dist/custom/character-count.d.ts +56 -0
- package/dist/custom/config-field-renderer.d.ts +1 -1
- package/dist/custom/config-panel-renderer.d.ts +3 -3
- package/dist/custom/export-progress-dialog.d.ts +1 -1
- package/dist/custom/filter-builder.d.ts +284 -4
- package/dist/custom/fullscreen-editor.d.ts +102 -0
- package/dist/custom/index.d.ts +28 -27
- package/dist/custom/item.d.ts +1 -1
- package/dist/custom/resizable.d.ts +1 -1
- package/dist/debug/index.d.ts +2 -2
- package/dist/index.css +36 -6
- package/dist/index.d.ts +21 -20
- package/dist/index.js +4991 -4464
- package/dist/index.umd.cjs +10 -6
- package/dist/lib/form-control-dom-props.d.ts +77 -0
- package/dist/lib/sidebar-cookie.d.ts +55 -0
- package/dist/lib/slider-thumb.d.ts +86 -0
- package/dist/notifications/index.d.ts +5 -5
- package/dist/renderers/action/action-bar.d.ts +3 -3
- package/dist/renderers/action/action-button.d.ts +12 -2
- package/dist/renderers/action/action-group.d.ts +10 -4
- package/dist/renderers/action/action-icon.d.ts +10 -2
- package/dist/renderers/action/action-menu.d.ts +20 -2
- package/dist/renderers/action/auto-trigger.d.ts +20 -0
- package/dist/renderers/action/visibility-gate.d.ts +81 -0
- package/dist/renderers/basic/elements.d.ts +3 -3
- package/dist/renderers/basic/html-elements.d.ts +1 -1
- package/dist/renderers/basic/record-picker.d.ts +10 -1
- package/dist/renderers/complex/data-table.d.ts +100 -4
- package/dist/renderers/form/validationRuleKeys.d.ts +45 -0
- package/dist/renderers/layout/containers.d.ts +2 -1
- package/dist/renderers/layout/react-page.d.ts +12 -2
- package/dist/share/index.d.ts +2 -2
- package/dist/ui/calendar.d.ts +1 -1
- package/dist/ui/carousel.d.ts +2 -2
- package/dist/ui/command.d.ts +1 -1
- package/dist/ui/form.d.ts +1 -1
- package/dist/ui/index.d.ts +47 -47
- package/dist/ui/pagination.d.ts +1 -1
- package/dist/ui/sidebar.d.ts +2 -2
- package/dist/ui/slider.d.ts +2 -1
- package/package.json +14 -14
- package/dist/custom/action-param-dialog.d.ts +0 -21
- package/dist/renderers/data-display/table.d.ts +0 -2
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ pnpm shadcn:check
|
|
|
27
27
|
pnpm shadcn:update button --backup
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
**📚 See [
|
|
30
|
+
**📚 See [README_SHADCN_SYNC.md](./README_SHADCN_SYNC.md) for the complete guide.**
|
|
31
31
|
|
|
32
32
|
## Installation
|
|
33
33
|
|
|
@@ -38,49 +38,56 @@ npm install @object-ui/components @object-ui/react @object-ui/core
|
|
|
38
38
|
**Peer Dependencies:**
|
|
39
39
|
- `react` ^18.0.0 || ^19.0.0
|
|
40
40
|
- `react-dom` ^18.0.0 || ^19.0.0
|
|
41
|
-
- `tailwindcss` ^
|
|
41
|
+
- `tailwindcss` ^4.2.1
|
|
42
42
|
|
|
43
43
|
## Setup
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
There is no `tailwind.config.js` step. This package is Tailwind 4, which is
|
|
46
|
+
configured in CSS: it has no such file of its own, and consuming it does not need
|
|
47
|
+
one on your side either.
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
### 1. Import Styles
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
module.exports = {
|
|
51
|
-
content: [
|
|
52
|
-
'./src/**/*.{js,jsx,ts,tsx}',
|
|
53
|
-
'./node_modules/@object-ui/components/**/*.{js,ts,jsx,tsx}'
|
|
54
|
-
],
|
|
55
|
-
// ... your config
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
### 2. Import Styles
|
|
60
|
-
|
|
61
|
-
Add to your main CSS file:
|
|
51
|
+
Add to your main CSS file, after your own Tailwind entry:
|
|
62
52
|
|
|
63
53
|
```css
|
|
64
|
-
@import '
|
|
54
|
+
@import 'tailwindcss';
|
|
55
|
+
@import '@object-ui/components/style.css';
|
|
65
56
|
```
|
|
66
57
|
|
|
67
|
-
|
|
58
|
+
`style.css` is the stylesheet this package compiles at build time from its own
|
|
59
|
+
sources. It already carries every utility its components use **and** the theme
|
|
60
|
+
tokens those utilities are built on — `bg-primary`, `border-input`, `ring-ring`
|
|
61
|
+
and the rest of the Shadcn palette — so importing it is the whole of the styling
|
|
62
|
+
setup.
|
|
63
|
+
|
|
64
|
+
You do **not** add a `@source` line for `node_modules/@object-ui/components`.
|
|
65
|
+
Pointing Tailwind at the published files generates the shape-only utilities a
|
|
66
|
+
second time and still cannot produce the themed ones, because the `@theme` block
|
|
67
|
+
they come from lives in this package's unpublished source. Your own Tailwind
|
|
68
|
+
entry goes on generating the classes your own source uses, as it always did.
|
|
69
|
+
|
|
70
|
+
### 2. Register Components
|
|
68
71
|
|
|
69
72
|
```tsx
|
|
70
|
-
import {
|
|
73
|
+
import { initializeComponents } from '@object-ui/components'
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
initializeComponents()
|
|
73
76
|
```
|
|
74
77
|
|
|
78
|
+
Importing the package already registers its components as a side effect;
|
|
79
|
+
`initializeComponents()` is the explicit call for bundlers that would otherwise
|
|
80
|
+
tree-shake that import away.
|
|
81
|
+
|
|
75
82
|
## Usage
|
|
76
83
|
|
|
77
84
|
### With SchemaRenderer
|
|
78
85
|
|
|
79
86
|
```tsx
|
|
80
87
|
import { SchemaRenderer } from '@object-ui/react'
|
|
81
|
-
import {
|
|
88
|
+
import { initializeComponents } from '@object-ui/components'
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
initializeComponents()
|
|
84
91
|
|
|
85
92
|
const schema = {
|
|
86
93
|
type: 'card',
|
|
@@ -197,30 +204,23 @@ All components accept `className` for Tailwind classes:
|
|
|
197
204
|
Register your own components:
|
|
198
205
|
|
|
199
206
|
```tsx
|
|
200
|
-
import {
|
|
207
|
+
import { ComponentRegistry } from '@object-ui/core'
|
|
201
208
|
import { Button } from '@object-ui/components'
|
|
202
209
|
|
|
203
|
-
function CustomButton(props) {
|
|
210
|
+
function CustomButton(props: Record<string, unknown>) {
|
|
204
211
|
return <Button {...props} className="my-custom-style" />
|
|
205
212
|
}
|
|
206
213
|
|
|
207
|
-
|
|
214
|
+
ComponentRegistry.register('custom-button', CustomButton)
|
|
208
215
|
```
|
|
209
216
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
`ComponentRegistry` is a process-level singleton exported by `@object-ui/core`;
|
|
218
|
+
`SchemaRenderer` resolves every `type` against it, so a component registered
|
|
219
|
+
here is renderable from schema anywhere in the app.
|
|
213
220
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
## Compatibility
|
|
221
|
+
## API Reference
|
|
217
222
|
|
|
218
|
-
|
|
219
|
-
- **Node.js:** ≥ 18
|
|
220
|
-
- **TypeScript:** ≥ 5.0 (strict mode)
|
|
221
|
-
- **`@objectstack/spec`:** ^3.3.0
|
|
222
|
-
- **`@objectstack/client`:** ^3.3.0
|
|
223
|
-
- **Tailwind CSS:** ≥ 3.4 (for packages with UI)
|
|
223
|
+
See [full documentation](https://objectui.org/docs/components) for detailed API reference.
|
|
224
224
|
|
|
225
225
|
## Links
|
|
226
226
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { VariantProps } from 'class-variance-authority';
|
|
2
|
-
import { Separator } from '../ui/separator';
|
|
2
|
+
import { Separator } from '../ui/separator.js';
|
|
3
3
|
declare const buttonGroupVariants: (props?: ({
|
|
4
4
|
orientation?: "horizontal" | "vertical" | null | undefined;
|
|
5
5
|
} & import('class-variance-authority/types').ClassProp) | undefined) => string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
export interface CharacterCountProps {
|
|
3
|
+
/**
|
|
4
|
+
* Length of the text this surface is counting. The inline surface passes the
|
|
5
|
+
* committed value's length; the fullscreen dialog passes the DRAFT's, which
|
|
6
|
+
* is the number that is true of what the user is looking at.
|
|
7
|
+
*/
|
|
8
|
+
length: number;
|
|
9
|
+
/**
|
|
10
|
+
* The declared cap. Callers render this component only when the field
|
|
11
|
+
* declares one, so it is non-optional here — a counter with no cap to count
|
|
12
|
+
* against has nothing to say and must not be mounted at all (an unconditional
|
|
13
|
+
* live region in every long-text field is the objectui#3408 defect back
|
|
14
|
+
* again).
|
|
15
|
+
*/
|
|
16
|
+
maxLength: number;
|
|
17
|
+
/**
|
|
18
|
+
* The description node's `id`, minted by the host from `useId()`.
|
|
19
|
+
*
|
|
20
|
+
* The host MUST also name it in the edited control's `aria-describedby` —
|
|
21
|
+
* this component renders the sentence, only the host knows which element is
|
|
22
|
+
* being described, and a description nothing references is a node no screen
|
|
23
|
+
* reader will ever reach. That association is asserted from the OUTSIDE (the
|
|
24
|
+
* tests resolve `aria-describedby` and concatenate the referenced nodes' text,
|
|
25
|
+
* exactly as an assistive technology does) rather than trusted here.
|
|
26
|
+
*
|
|
27
|
+
* Two surfaces of one widget therefore need two ids: the dialog's draft and
|
|
28
|
+
* the inline value diverge the moment the user types in the dialog, so one
|
|
29
|
+
* shared id would point both controls at whichever sentence rendered last.
|
|
30
|
+
*/
|
|
31
|
+
descriptionId: string;
|
|
32
|
+
/**
|
|
33
|
+
* Render node 3, the threshold-gated debounced `aria-live` status region.
|
|
34
|
+
*
|
|
35
|
+
* Required on purpose — see the component doc above. `false` removes the
|
|
36
|
+
* region from the DOM entirely (not merely empties it), which is the only
|
|
37
|
+
* form of "no live region" a test can tell apart from "a live region that
|
|
38
|
+
* happens to be silent right now".
|
|
39
|
+
*/
|
|
40
|
+
announceNearLimit: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Classes for the visible digits. Required because placement is entirely the
|
|
43
|
+
* surface's business — inline pins them to the textarea's bottom-right
|
|
44
|
+
* corner, the dialog lays them out as a footer item — and a default borrowed
|
|
45
|
+
* from either one would position the other wrongly while looking deliberate.
|
|
46
|
+
*/
|
|
47
|
+
className: string;
|
|
48
|
+
/**
|
|
49
|
+
* `data-testid` for the visible digits, namespaced per surface
|
|
50
|
+
* (`textarea-character-count`, `textarea-fullscreen-character-count`) so a
|
|
51
|
+
* test that finds digits can say WHICH surface it found them on. Same
|
|
52
|
+
* convention, same reason, as `FullscreenFieldEditor`'s `testIdPrefix`.
|
|
53
|
+
*/
|
|
54
|
+
testId: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function CharacterCount({ length, maxLength, descriptionId, announceNearLimit, className, testId, }: CharacterCountProps): React.JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigPanelSchema } from '../types/config-panel';
|
|
1
|
+
import { ConfigPanelSchema } from '../types/config-panel.js';
|
|
2
2
|
/**
|
|
3
3
|
* ObjectUI
|
|
4
4
|
* Copyright (c) 2024-present ObjectStack Inc.
|
|
@@ -32,9 +32,9 @@ export interface ConfigPanelRendererProps {
|
|
|
32
32
|
className?: string;
|
|
33
33
|
/** Additional inline styles applied to the panel root (e.g. to override `--config-panel-width`). */
|
|
34
34
|
style?: React.CSSProperties;
|
|
35
|
-
/** Label for save button (default:
|
|
35
|
+
/** Label for save button (default: the locale pack's `common.save`) */
|
|
36
36
|
saveLabel?: string;
|
|
37
|
-
/** Label for discard button (default:
|
|
37
|
+
/** Label for discard button (default: the locale pack's `common.discard`) */
|
|
38
38
|
discardLabel?: string;
|
|
39
39
|
/** Ref for the panel root element */
|
|
40
40
|
panelRef?: React.Ref<HTMLDivElement>;
|
|
@@ -42,7 +42,7 @@ export interface FilterBuilderProps {
|
|
|
42
42
|
value: string;
|
|
43
43
|
label: string;
|
|
44
44
|
}>;
|
|
45
|
-
/** For lookup/master_detail/user
|
|
45
|
+
/** For lookup/master_detail/user fields — referenced object name */
|
|
46
46
|
referenceTo?: string;
|
|
47
47
|
/** Display field on the referenced object (defaults to "name") */
|
|
48
48
|
displayField?: string;
|
|
@@ -53,6 +53,17 @@ export interface FilterBuilderProps {
|
|
|
53
53
|
onChange?: (value: FilterGroup) => void;
|
|
54
54
|
className?: string;
|
|
55
55
|
showClearAll?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Opt-in operator ids ({@link OPT_IN_OPERATORS}) this instance may offer, on
|
|
58
|
+
* top of the ones every FilterBuilder draws.
|
|
59
|
+
*
|
|
60
|
+
* The dropdown is shared by consumers that persist a filter into DIFFERENT
|
|
61
|
+
* dialects, and an operator one dialect can carry is not automatically
|
|
62
|
+
* storable in another — so whether an opt-in operator is offered is the
|
|
63
|
+
* consumer's answer, not this component's. See {@link OPT_IN_OPERATORS} for
|
|
64
|
+
* the measurement that made this a prop instead of one global list.
|
|
65
|
+
*/
|
|
66
|
+
extraOperators?: readonly string[];
|
|
56
67
|
}
|
|
57
68
|
declare const defaultOperators: readonly [{
|
|
58
69
|
readonly value: "equals";
|
|
@@ -63,6 +74,9 @@ declare const defaultOperators: readonly [{
|
|
|
63
74
|
}, {
|
|
64
75
|
readonly value: "contains";
|
|
65
76
|
readonly label: "Contains";
|
|
77
|
+
}, {
|
|
78
|
+
readonly value: "containsCaseInsensitive";
|
|
79
|
+
readonly label: "Contains (ignore case)";
|
|
66
80
|
}, {
|
|
67
81
|
readonly value: "notContains";
|
|
68
82
|
readonly label: "Does not contain";
|
|
@@ -120,7 +134,8 @@ declare const defaultOperators: readonly [{
|
|
|
120
134
|
}];
|
|
121
135
|
/**
|
|
122
136
|
* The FilterBuilder's own operator vocabulary — every id its dropdown can
|
|
123
|
-
* hold, derived from the operators it actually renders.
|
|
137
|
+
* hold, derived from the operators it actually renders. Includes the opt-in
|
|
138
|
+
* ids: they are drawable, just not offered unconditionally.
|
|
124
139
|
*
|
|
125
140
|
* Exported because several translation tables map an external vocabulary
|
|
126
141
|
* (the spec's `VIEW_FILTER_OPERATORS`, Mongo `$`-tokens) *onto* this one, and
|
|
@@ -129,10 +144,275 @@ declare const defaultOperators: readonly [{
|
|
|
129
144
|
* rather than restating it, so adding an operator here is the only edit
|
|
130
145
|
* needed to widen them.
|
|
131
146
|
*/
|
|
132
|
-
export declare const FILTER_BUILDER_OPERATORS: ("endsWith" | "startsWith" | "in" | "after" | "equals" | "notEquals" | "contains" | "notContains" | "isEmpty" | "isNotEmpty" | "greaterThan" | "lessThan" | "greaterOrEqual" | "lessOrEqual" | "before" | "between" | "notIn" | "isNull" | "isNotNull" | "exists" | "notExists")[];
|
|
147
|
+
export declare const FILTER_BUILDER_OPERATORS: ("endsWith" | "startsWith" | "in" | "after" | "equals" | "notEquals" | "contains" | "containsCaseInsensitive" | "notContains" | "isEmpty" | "isNotEmpty" | "greaterThan" | "lessThan" | "greaterOrEqual" | "lessOrEqual" | "before" | "between" | "notIn" | "isNull" | "isNotNull" | "exists" | "notExists")[];
|
|
133
148
|
/** An operator id the FilterBuilder can render. */
|
|
134
149
|
export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'];
|
|
135
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Operator ids for which this builder renders NO value input — so "no value"
|
|
152
|
+
* is the row's FINISHED state, not an unfinished one (objectui#4744).
|
|
153
|
+
*
|
|
154
|
+
* This is the source of truth for that distinction, and it lives here because
|
|
155
|
+
* this component is the thing that decides it: `needsValueInput` below is
|
|
156
|
+
* defined as the complement of this set, so the two cannot say different
|
|
157
|
+
* things. Every consumer that has to tell a complete value-less row from a
|
|
158
|
+
* half-filled one reads it FROM here rather than restating it:
|
|
159
|
+
*
|
|
160
|
+
* - `plugin-list`'s `convertFilterGroupToAST` — what the live grid QUERIES;
|
|
161
|
+
* - `app-shell`'s `foldFilterGroupToSpecRules` — what a saved view PERSISTS
|
|
162
|
+
* (its `VALUELESS_FILTER_OPERATORS` is this set plus the canonical spec
|
|
163
|
+
* spellings, which only that layer sees).
|
|
164
|
+
*
|
|
165
|
+
* Placement is forced by the dependency graph — `app-shell` depends on
|
|
166
|
+
* `plugin-list` depends on this package, so this is the only module all three
|
|
167
|
+
* can reach — and it is also where the set belongs on the merits: the fact it
|
|
168
|
+
* states is a fact about what this dropdown draws.
|
|
169
|
+
*
|
|
170
|
+
* Why it exists at all: each consumer used to keep its own copy, and the
|
|
171
|
+
* live-grid copy listed only `isEmpty`/`isNotEmpty`. A fresh row is seeded
|
|
172
|
+
* `{ operator: 'equals', value: '' }` and the operator dropdown preserves
|
|
173
|
+
* `value`, so picking **Is null** as the first action left `value: ''` — the
|
|
174
|
+
* grid read that as an unfinished row, dropped it, and applied NO filter at
|
|
175
|
+
* all while the panel showed one. Silent, and every record came back.
|
|
176
|
+
*
|
|
177
|
+
* `exists` / `notExists` stay listed even though `OPT_IN_OPERATORS` withholds
|
|
178
|
+
* them from most consumers (objectui#4736): the builder still draws them
|
|
179
|
+
* value-less for `FilterConditionField`, which is exactly what this set
|
|
180
|
+
* describes. Whether a consumer can EXPRESS an operator is a separate
|
|
181
|
+
* question, answered by `OPT_IN_OPERATORS` and by each consumer's parity test.
|
|
182
|
+
*/
|
|
183
|
+
export declare const VALUELESS_FILTER_BUILDER_OPERATORS: ReadonlySet<string>;
|
|
184
|
+
/**
|
|
185
|
+
* The SHAPE an operator's `value` must have — the question
|
|
186
|
+
* `ViewFilterRuleSchema` asks, asked here in the one place that decides what
|
|
187
|
+
* input the user is given (objectui#3958).
|
|
188
|
+
*
|
|
189
|
+
* - `list` — membership over any arity, `[]` included (`in` / `not_in`).
|
|
190
|
+
* - `pair` — exactly two bounds, in order (`between`).
|
|
191
|
+
* - `scalar` — everything else, including the value-less operators above
|
|
192
|
+
* (they draw no input at all, so their shape never reaches storage).
|
|
193
|
+
*/
|
|
194
|
+
export type FilterValueArity = "scalar" | "list" | "pair";
|
|
195
|
+
/**
|
|
196
|
+
* Which value shape `operator` takes, in EITHER dialect.
|
|
197
|
+
*
|
|
198
|
+
* The argument is folded through the spec's own `normalizeFilterOperator`
|
|
199
|
+
* first, so the builder's camelCase dropdown ids (`notIn`) and the canonical
|
|
200
|
+
* spellings a stored rule carries (`not_in`) land on the same answer. That
|
|
201
|
+
* fold is the whole point: one vocabulary, declared upstream, consulted here —
|
|
202
|
+
* not a second local dialect that has to be kept in sync by hand.
|
|
203
|
+
*
|
|
204
|
+
* @internal exported for tests
|
|
205
|
+
*/
|
|
206
|
+
export declare function filterValueArity(operator: string): FilterValueArity;
|
|
207
|
+
/**
|
|
208
|
+
* Re-shape a row's `value` for the operator it is being changed TO.
|
|
209
|
+
*
|
|
210
|
+
* Switching the operator dropdown used to update `operator` alone, so the
|
|
211
|
+
* value the previous family had produced simply survived the switch — a scalar
|
|
212
|
+
* `"acme"` (or the seed `""`) still sitting there under `in`, which is
|
|
213
|
+
* precisely the shape `ViewFilterRuleSchema` refuses. Re-shaping rather than
|
|
214
|
+
* blanking keeps what the user typed wherever it still means something:
|
|
215
|
+
*
|
|
216
|
+
* - to `list`: a scalar becomes a one-element list (`"acme"` → `["acme"]`),
|
|
217
|
+
* an empty scalar becomes `[]` — a real, spec-valid predicate;
|
|
218
|
+
* - to `pair`: the first two entries become `[min, max]`, padded with `""`;
|
|
219
|
+
* with nothing to carry it collapses to `[]`, which the write path reads
|
|
220
|
+
* as "row not filled in yet" and drops, exactly as it drops an empty
|
|
221
|
+
* scalar today;
|
|
222
|
+
* - to `scalar`: the first entry of a list survives; the rest cannot, and
|
|
223
|
+
* dropping them is the honest answer — `equals` compares against one value.
|
|
224
|
+
*
|
|
225
|
+
* @internal exported for tests
|
|
226
|
+
*/
|
|
227
|
+
export declare function reshapeFilterValue(value: FilterBuilderCondition["value"], nextOperator: string): FilterBuilderCondition["value"];
|
|
228
|
+
/**
|
|
229
|
+
* The operator a row keeps after its FIELD is changed, given the operators the
|
|
230
|
+
* NEW field offers (objectui#4768).
|
|
231
|
+
*
|
|
232
|
+
* Each field type has its own operator bucket, and the buckets are not nested:
|
|
233
|
+
* a `select` column offers `in` / `notIn`, a `text` column does not. Changing
|
|
234
|
+
* the field used to write `{ field }` alone, so the row's operator survived
|
|
235
|
+
* into a bucket that no longer contains it — `in` on a text column. The Radix
|
|
236
|
+
* trigger then had nothing to render (its `SelectValue` matches against the
|
|
237
|
+
* `SelectItem`s actually mounted) and went BLANK, while the row went on
|
|
238
|
+
* filtering by an operator the user could no longer see or reach.
|
|
239
|
+
*
|
|
240
|
+
* So the two edits are one edit, exactly as `changeOperator` made the operator
|
|
241
|
+
* and its value shape one edit: an operator the new bucket still offers is the
|
|
242
|
+
* user's choice and is KEPT; one it does not is replaced by the bucket's first
|
|
243
|
+
* entry (`equals` for every bucket this builder draws), and the caller then
|
|
244
|
+
* re-shapes the value for that operator's family.
|
|
245
|
+
*
|
|
246
|
+
* Membership is decided CANONICALLY, through the spec's own
|
|
247
|
+
* `normalizeFilterOperator` — the same fold `filterValueArity` uses. A stored
|
|
248
|
+
* rule can reach this builder spelled `not_in` while the dropdown lists the
|
|
249
|
+
* alias `notIn`; those are one operator, so a select→lookup switch must not
|
|
250
|
+
* silently rewrite the author's operator to `equals` just because the two
|
|
251
|
+
* spellings differ. The fold is safe to compare through because it is
|
|
252
|
+
* INJECTIVE over this builder's vocabulary — all 22 ids in `defaultOperators`
|
|
253
|
+
* normalize to 22 distinct canonical operators, pinned in
|
|
254
|
+
* `filter-builder-field-switch-operator.test.tsx` — so no two OFFERED
|
|
255
|
+
* operators can ever collapse onto one another.
|
|
256
|
+
*
|
|
257
|
+
* @internal exported for tests
|
|
258
|
+
*/
|
|
259
|
+
export declare function reconcileOperatorForField(operator: string, offeredOperators: ReadonlyArray<{
|
|
260
|
+
value: string;
|
|
261
|
+
}>): string;
|
|
262
|
+
/**
|
|
263
|
+
* Re-TYPE a row's `value` for the field it is being changed TO — the type
|
|
264
|
+
* question, standing beside {@link reshapeFilterValue}'s shape question
|
|
265
|
+
* (objectui#4781).
|
|
266
|
+
*
|
|
267
|
+
* Changing a row's field used to carry the value through untouched whenever the
|
|
268
|
+
* shape did not have to change, which is right about the shape and wrong about
|
|
269
|
+
* the type: the value input is re-drawn from the NEW field's type, and an
|
|
270
|
+
* `<input type="number">` renders a non-numeric value as BLANK. So a `text`
|
|
271
|
+
* row filtered `equals "acme"`, pointed at a number column, showed an empty box
|
|
272
|
+
* while the row went on carrying `"acme"` — `foldFilterGroupToSpecRules`
|
|
273
|
+
* persisted it and the live grid queried `amount equals "acme"`. The same
|
|
274
|
+
* invisible-value shape objectui#4768 closed on the operator, one column over.
|
|
275
|
+
*
|
|
276
|
+
* Carry-if-possible, exactly as `reshapeFilterValue` carries what it can across
|
|
277
|
+
* a family change: `"42"` moved to a number column becomes `42` and survives;
|
|
278
|
+
* `"acme"` cannot be read as a number by any clean reading, so it clears to the
|
|
279
|
+
* family's empty shape — scalar `""`, list `[]`, pair `[]`. What a value can
|
|
280
|
+
* become is decided in one place, {@link convertScalarToFamily}.
|
|
281
|
+
*
|
|
282
|
+
* The arity is read from the operator through the same `filterValueArity` fold
|
|
283
|
+
* the rest of this component uses, because each shape clears differently:
|
|
284
|
+
*
|
|
285
|
+
* - `scalar` — converted, or `""`.
|
|
286
|
+
* - `list` — converted ENTRY BY ENTRY, keeping the ones that carry:
|
|
287
|
+
* `["42", "acme"]` → `[42]`, and `[]` when none do. Reachable today only
|
|
288
|
+
* between the two buckets that offer `in`/`notIn` (`select` ↔ `lookup`),
|
|
289
|
+
* which are both text-family, so the conversion is the identity there; it
|
|
290
|
+
* is written for the family the operator lands in rather than for today's
|
|
291
|
+
* buckets, and pinned directly on this helper.
|
|
292
|
+
* - `pair` — both bounds converted independently, an unconvertible bound
|
|
293
|
+
* becoming `""` so the range keeps its two slots; both empty collapses to
|
|
294
|
+
* `[]`, the "not filled in yet" shape `reshapeFilterValue` also produces
|
|
295
|
+
* and the write path drops.
|
|
296
|
+
*
|
|
297
|
+
* @internal exported for tests
|
|
298
|
+
*/
|
|
299
|
+
export declare function retypeFilterValue(value: FilterBuilderCondition["value"], fieldType: string | undefined, operator: string): FilterBuilderCondition["value"];
|
|
300
|
+
/** The shape of a `fields` entry that the option-domain helpers below read. */
|
|
301
|
+
type OptionBearingField = {
|
|
302
|
+
type?: string;
|
|
303
|
+
options?: ReadonlyArray<{
|
|
304
|
+
value: string;
|
|
305
|
+
label: string;
|
|
306
|
+
}>;
|
|
307
|
+
};
|
|
308
|
+
/**
|
|
309
|
+
* Narrow a row's `value` to the option DOMAIN of the field it is being changed
|
|
310
|
+
* TO — the value-domain question, standing beside {@link retypeFilterValue}'s
|
|
311
|
+
* type question and {@link reshapeFilterValue}'s shape question
|
|
312
|
+
* (objectui#4874).
|
|
313
|
+
*
|
|
314
|
+
* objectui#4781 settled the TYPE half and deliberately stopped there: `select`
|
|
315
|
+
* and `lookup` are text-family, so `"acme"` is a value those columns can
|
|
316
|
+
* perfectly well HOLD. What it cannot be is one of the column's options — and
|
|
317
|
+
* the control drawn for those columns is a Radix Select, whose trigger renders
|
|
318
|
+
* only what one of its mounted `SelectItem`s carries. So a text row filtered
|
|
319
|
+
* `equals "acme"`, pointed at a picklist column of `won`/`lost`, showed a BLANK
|
|
320
|
+
* trigger while the row went on carrying `"acme"`:
|
|
321
|
+
* `foldFilterGroupToSpecRules` persisted it and the live grid queried
|
|
322
|
+
* `stage equals "acme"`. The third face of the same invisible value —
|
|
323
|
+
* objectui#4768 on the operator, objectui#4781 on the value's type, this one on
|
|
324
|
+
* its domain.
|
|
325
|
+
*
|
|
326
|
+
* Ruled 2026-08-17 as **A + C combined**, and the A half is this function:
|
|
327
|
+
* clear on non-membership, but ONLY where the option set is the column's whole
|
|
328
|
+
* domain ({@link hasStaticOptionDomain}). A remote/async column's value is
|
|
329
|
+
* returned untouched — clearing it would delete a perfectly valid lookup id on
|
|
330
|
+
* the strength of a local page that never claimed to be complete, and the C
|
|
331
|
+
* half (the render path's temporary option) is what keeps it visible instead.
|
|
332
|
+
* Option B — keep the value and tolerate it being invisible — stays rejected,
|
|
333
|
+
* as it was on objectui#4781.
|
|
334
|
+
*
|
|
335
|
+
* The empty shape a cleared value falls to is objectui#4781's, unchanged and
|
|
336
|
+
* read from the same `filterValueArity` fold:
|
|
337
|
+
*
|
|
338
|
+
* - `scalar` — `""`.
|
|
339
|
+
* - `list` — filtered ENTRY BY ENTRY, keeping the members: `["acme", "won"]`
|
|
340
|
+
* → `["won"]`, and `[]` when none survive. Per-entry rather than
|
|
341
|
+
* all-or-nothing because each entry is its own filter term, and one bad
|
|
342
|
+
* term is no reason to throw away the user's good ones.
|
|
343
|
+
* - `pair` — untouched; `between` is not option-driven.
|
|
344
|
+
*
|
|
345
|
+
* @internal exported for tests
|
|
346
|
+
*/
|
|
347
|
+
export declare function narrowFilterValueToOptions(value: FilterBuilderCondition["value"], field: OptionBearingField | undefined, operator: string): FilterBuilderCondition["value"];
|
|
348
|
+
/**
|
|
349
|
+
* Is this row's value FINISHED for the operator it carries — the arity-aware
|
|
350
|
+
* reading of "the user has filled this in" (objectstack#8815).
|
|
351
|
+
*
|
|
352
|
+
* Both write paths used to ask this with one shape-blind predicate:
|
|
353
|
+
*
|
|
354
|
+
* ```ts
|
|
355
|
+
* value == null || value === '' || (Array.isArray(value) && value.length === 0)
|
|
356
|
+
* ```
|
|
357
|
+
*
|
|
358
|
+
* which is right for `scalar` and `list` and WRONG for `pair`. A `between` row
|
|
359
|
+
* with one bound typed is `["2024-01-01", ""]` — an array of length 2, so it
|
|
360
|
+
* read as complete, and both consumers carried it through. The live grid then
|
|
361
|
+
* queried a range with an empty upper bound and the server refused the whole
|
|
362
|
+
* view (`400 INVALID_FILTER`), which is the "该视图的查询被拒绝" the reporter
|
|
363
|
+
* hit; the saved-view path persisted the same half-range, so the refusal came
|
|
364
|
+
* back on every later read of that view.
|
|
365
|
+
*
|
|
366
|
+
* The spec cannot catch this on the way past — measured against
|
|
367
|
+
* `ViewFilterRuleSchema` on `@objectstack/spec` 17.0.0: `["2024-01-01", ""]` is
|
|
368
|
+
* ACCEPTED (it counts the two slots, not what is in them), while a scalar or a
|
|
369
|
+
* one-element array is refused. So the empty bound sails through authoring
|
|
370
|
+
* validation and only dies at query time, one layer too late to be actionable.
|
|
371
|
+
* That makes it the PRODUCER's job to not emit it — this component is what
|
|
372
|
+
* decides when a row is finished, exactly as it already decides which operators
|
|
373
|
+
* finish with no value at all ({@link VALUELESS_FILTER_BUILDER_OPERATORS}), and
|
|
374
|
+
* for the same reason: each consumer keeping its own copy is how the two came to
|
|
375
|
+
* disagree in the first place.
|
|
376
|
+
*
|
|
377
|
+
* The arity is read through the same `filterValueArity` fold the rest of this
|
|
378
|
+
* file uses, so a stored rule spelled canonically gets the same answer as the
|
|
379
|
+
* dropdown's own id.
|
|
380
|
+
*
|
|
381
|
+
* What each shape needs:
|
|
382
|
+
*
|
|
383
|
+
* - `pair` — BOTH bounds present. A range missing an end is not a narrower
|
|
384
|
+
* range, it is a query the server refuses.
|
|
385
|
+
* - `list` — at least one entry; `[]` is the "nothing chosen yet" shape.
|
|
386
|
+
* - `scalar` — anything that is not the unfilled shape.
|
|
387
|
+
*
|
|
388
|
+
* "Present" is {@link isValueUnset}, never `!bound` (objectui#4873): `0` is a
|
|
389
|
+
* real bound on a number column and `false` is a real value, and reading either
|
|
390
|
+
* as unfilled would drop a filter the user can see on screen.
|
|
391
|
+
*
|
|
392
|
+
* This answers the VALUE question only. Whether an operator wants a value at all
|
|
393
|
+
* stays with each caller, because the two layers see different vocabularies:
|
|
394
|
+
* `app-shell` also has to recognise the canonical spellings (`is_null`) that
|
|
395
|
+
* never reach this dropdown.
|
|
396
|
+
*
|
|
397
|
+
* @internal exported for tests
|
|
398
|
+
*/
|
|
399
|
+
export declare function isFilterValueComplete(operator: string, value: FilterBuilderCondition["value"] | undefined | null): boolean;
|
|
400
|
+
/**
|
|
401
|
+
* The operators the dropdown offers for a field of `fieldType`, given the
|
|
402
|
+
* opt-in ids this instance was granted.
|
|
403
|
+
*
|
|
404
|
+
* A pure function rather than a closure so the selection rule — and above all
|
|
405
|
+
* the {@link OPT_IN_OPERATORS} gate, whose whole job is to keep an operator OFF
|
|
406
|
+
* the surfaces that cannot store it — is assertable without driving a Radix
|
|
407
|
+
* popover open in jsdom.
|
|
408
|
+
*
|
|
409
|
+
* @internal exported for tests
|
|
410
|
+
*/
|
|
411
|
+
export declare function operatorsForFieldType(fieldType: string | undefined, extraOperators?: readonly string[]): ReadonlyArray<{
|
|
412
|
+
value: string;
|
|
413
|
+
label: string;
|
|
414
|
+
}>;
|
|
415
|
+
declare function FilterBuilder({ fields, value, onChange, className, showClearAll, extraOperators, }: FilterBuilderProps): React.JSX.Element;
|
|
136
416
|
declare namespace FilterBuilder {
|
|
137
417
|
var displayName: string;
|
|
138
418
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The accessibility attributes the dialog's injected editor MUST carry, handed
|
|
4
|
+
* to `children` as its required fourth argument (objectui#4824 / #4832).
|
|
5
|
+
*
|
|
6
|
+
* Shaped as a spreadable set of real DOM attributes rather than the ids behind
|
|
7
|
+
* them, on purpose: `{...aria}` is the whole integration, so a host cannot get
|
|
8
|
+
* the composition subtly wrong, cannot name a node outside the dialog, and
|
|
9
|
+
* cannot compute its own `aria-invalid` from a prop it forgot to plumb — the
|
|
10
|
+
* three ways every one of the three surfaces got this wrong before.
|
|
11
|
+
*/
|
|
12
|
+
export interface FullscreenEditorAria {
|
|
13
|
+
/**
|
|
14
|
+
* The dialog title's text node — the field label (#3393) reused as the
|
|
15
|
+
* control's accessible name rather than a second, string-valued author for it.
|
|
16
|
+
*/
|
|
17
|
+
'aria-labelledby': string;
|
|
18
|
+
/** Mirrors `error`, so the dialog cannot disagree with the inline control. */
|
|
19
|
+
'aria-invalid': boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The dialog-local message node. A SINGLE id, present only when `error` is —
|
|
22
|
+
* `aria-errormessage` is exposed only alongside `aria-invalid="true"`, and
|
|
23
|
+
* this repo's toolchain does not accept an id list here.
|
|
24
|
+
*/
|
|
25
|
+
'aria-errormessage'?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface FullscreenEditorProps {
|
|
28
|
+
/**
|
|
29
|
+
* The committed value. Seeds the draft each time the dialog opens — read at
|
|
30
|
+
* open time, not held, so a value that changes underneath while the dialog is
|
|
31
|
+
* closed is picked up on the next open.
|
|
32
|
+
*/
|
|
33
|
+
value: string;
|
|
34
|
+
/** Called once with the draft when the user confirms. Gated — see above. */
|
|
35
|
+
onCommit: (next: string) => void;
|
|
36
|
+
/** Field label — the dialog title and part of the toggle's accessible name. */
|
|
37
|
+
label?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Namespace for this surface's fullscreen test ids:
|
|
40
|
+
* `<prefix>-fullscreen-toggle` / `-dialog` / `-save`. One form can contain a
|
|
41
|
+
* textarea AND a markdown field, so a single shared id would leave a test
|
|
42
|
+
* unable to say which surface it had found. The injected editor's own test id
|
|
43
|
+
* belongs to `children` for the same reason.
|
|
44
|
+
*/
|
|
45
|
+
testIdPrefix: string;
|
|
46
|
+
/**
|
|
47
|
+
* The host field is shown plainly and is not editable. Renders NO affordance
|
|
48
|
+
* at all — see the `readOnly` / `disabled` section above for why this is not
|
|
49
|
+
* a disabled toggle.
|
|
50
|
+
*
|
|
51
|
+
* **Producers**: the form renderer's built-in `textarea` branch, which
|
|
52
|
+
* resolves it from the field's static `readonly` and from a `readonlyWhen`
|
|
53
|
+
* CEL rule. The registered widgets early-return a read-only display before
|
|
54
|
+
* they render this component, so they never pass it.
|
|
55
|
+
*/
|
|
56
|
+
readOnly?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The host field is not interactive. Disables the toggle, the "Done" button
|
|
59
|
+
* and the write-back, and is handed to `children` so the host's own editor
|
|
60
|
+
* renders inert too.
|
|
61
|
+
*
|
|
62
|
+
* **Producer**: the form renderer computes `disabled || fieldDisabled ||
|
|
63
|
+
* isSubmitting || optionGroupGated` and both forwards it to registered
|
|
64
|
+
* widgets and applies it on the built-in branch.
|
|
65
|
+
*/
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The field's validation message, or `undefined` when the field is valid.
|
|
69
|
+
*
|
|
70
|
+
* **Required, not optional** (objectui#4824). Every consumer already has it —
|
|
71
|
+
* the registered widgets take `error` off the widget props contract
|
|
72
|
+
* (objectui#3222), the built-in branch reads `fieldState.error?.message` — and
|
|
73
|
+
* an omitted `error` reproduces this issue exactly: a dialog that announces
|
|
74
|
+
* `aria-invalid="false"` while the same field's inline control announces
|
|
75
|
+
* `true`. A key the type system makes every call site answer cannot be
|
|
76
|
+
* forgotten by the next surface; an optional one was, three times.
|
|
77
|
+
*
|
|
78
|
+
* **Producers**: `TextAreaField`, `RichTextField`, and the form renderer's
|
|
79
|
+
* built-in `textarea` branch (which reads it off the PRE-strip props, exactly
|
|
80
|
+
* like `label` — `stripRendererOnlyProps` discards `error` for the DOM).
|
|
81
|
+
*/
|
|
82
|
+
error: string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The editor itself, rendered inside the dialog body against the draft. The
|
|
85
|
+
* host passes the SAME editor it renders inline.
|
|
86
|
+
*
|
|
87
|
+
* The third argument is this component's `disabled`, and the host is expected
|
|
88
|
+
* to put it on the control it renders: only the host knows which element its
|
|
89
|
+
* editor's disabled state belongs on. Ignoring it is not a write-back hole —
|
|
90
|
+
* `onCommit` is gated here regardless — but it does leave a control that looks
|
|
91
|
+
* editable while the field is not, so every in-repo host applies it.
|
|
92
|
+
*
|
|
93
|
+
* The fourth argument is the accessibility set this component owns — name and
|
|
94
|
+
* validation state — and it is spread onto the control the host renders. See
|
|
95
|
+
* `FullscreenEditorAria` and the section above for why it is computed here
|
|
96
|
+
* rather than by each host.
|
|
97
|
+
*/
|
|
98
|
+
children: (draft: string, setDraft: (next: string) => void, disabled: boolean, aria: FullscreenEditorAria) => React.ReactNode;
|
|
99
|
+
/** Optional footer status for the draft (e.g. a character counter). */
|
|
100
|
+
footer?: (draft: string) => React.ReactNode;
|
|
101
|
+
}
|
|
102
|
+
export declare function FullscreenEditor({ value, onCommit, label, testIdPrefix, readOnly, disabled, error, children, footer, }: FullscreenEditorProps): React.JSX.Element | null;
|