@script-it/json-views-react 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +301 -0
- package/THIRD_PARTY_NOTICES.txt +4917 -0
- package/dist/browser-device.d.ts +21 -0
- package/dist/browser-device.js +22 -0
- package/dist/file-content-edit-state.d.ts +10 -0
- package/dist/file-content-edit-state.js +1 -0
- package/dist/file-content-view.d.ts +8 -0
- package/dist/file-content-view.js +7 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/json/copy-json.d.ts +15 -0
- package/dist/json/copy-json.js +52 -0
- package/dist/json/html-preview.d.ts +5 -0
- package/dist/json/html-preview.js +12 -0
- package/dist/json/html-view/native-control.d.ts +44 -0
- package/dist/json/html-view/native-control.js +96 -0
- package/dist/json/html-view/settings.d.ts +2 -0
- package/dist/json/html-view/settings.js +58 -0
- package/dist/json/html-view/template.d.ts +12 -0
- package/dist/json/html-view/template.js +55 -0
- package/dist/json/html-view.d.ts +9 -0
- package/dist/json/html-view.js +163 -0
- package/dist/json/json-content.d.ts +61 -0
- package/dist/json/json-content.js +433 -0
- package/dist/json/json-source-editor.d.ts +36 -0
- package/dist/json/json-source-editor.js +203 -0
- package/dist/json/json-view.d.ts +3 -0
- package/dist/json/json-view.js +322 -0
- package/dist/json/kanban-view.d.ts +20 -0
- package/dist/json/kanban-view.js +328 -0
- package/dist/json/long-text.d.ts +3 -0
- package/dist/json/long-text.js +6 -0
- package/dist/json/record-view.d.ts +38 -0
- package/dist/json/record-view.js +85 -0
- package/dist/json/row-mutations.d.ts +7 -0
- package/dist/json/row-mutations.js +93 -0
- package/dist/json/schema-value.d.ts +57 -0
- package/dist/json/schema-value.js +294 -0
- package/dist/json/source-default.d.ts +1 -0
- package/dist/json/source-default.js +1 -0
- package/dist/json/source-edit-capabilities.d.ts +8 -0
- package/dist/json/source-edit-capabilities.js +22 -0
- package/dist/json/source-literals.d.ts +15 -0
- package/dist/json/source-literals.js +24 -0
- package/dist/json/source-recovery.d.ts +32 -0
- package/dist/json/source-recovery.js +38 -0
- package/dist/json/source-selection.d.ts +3 -0
- package/dist/json/source-selection.js +16 -0
- package/dist/json/table-view.d.ts +20 -0
- package/dist/json/table-view.js +300 -0
- package/dist/json/use-kanban-card-drag.d.ts +13 -0
- package/dist/json/use-kanban-card-drag.js +255 -0
- package/dist/json/use-kanban-column-drag.d.ts +14 -0
- package/dist/json/use-kanban-column-drag.js +179 -0
- package/dist/json/view-diagnostics.d.ts +4 -0
- package/dist/json/view-diagnostics.js +26 -0
- package/dist/json/view-model.d.ts +27 -0
- package/dist/json/view-model.js +180 -0
- package/dist/json/view-save-status.d.ts +12 -0
- package/dist/json/view-save-status.js +38 -0
- package/dist/json/view-settings.d.ts +36 -0
- package/dist/json/view-settings.js +661 -0
- package/dist/json/view-types.d.ts +47 -0
- package/dist/json/view-types.js +4 -0
- package/dist/json/viewers/general/general-json-view.d.ts +46 -0
- package/dist/json/viewers/general/general-json-view.js +163 -0
- package/dist/json/viewers/general/json-flatten.d.ts +33 -0
- package/dist/json/viewers/general/json-flatten.js +51 -0
- package/dist/json/viewers/general/json-node-view.d.ts +43 -0
- package/dist/json/viewers/general/json-node-view.js +215 -0
- package/dist/lib/clipboard.d.ts +1 -0
- package/dist/lib/clipboard.js +24 -0
- package/dist/lib/cn.d.ts +2 -0
- package/dist/lib/cn.js +5 -0
- package/dist/lib/file-data.d.ts +7 -0
- package/dist/lib/file-data.js +5 -0
- package/dist/lib/floating-portal-selectors.d.ts +4 -0
- package/dist/lib/floating-portal-selectors.js +4 -0
- package/dist/lib/time.d.ts +24 -0
- package/dist/lib/time.js +60 -0
- package/dist/lib/ui-size.d.ts +28 -0
- package/dist/lib/ui-size.js +39 -0
- package/dist/lib/use-anchored-position.d.ts +5 -0
- package/dist/lib/use-anchored-position.js +65 -0
- package/dist/lib/use-dismiss.d.ts +31 -0
- package/dist/lib/use-dismiss.js +65 -0
- package/dist/primitives/boolean-toggle.d.ts +10 -0
- package/dist/primitives/boolean-toggle.js +11 -0
- package/dist/primitives/button.d.ts +11 -0
- package/dist/primitives/button.js +34 -0
- package/dist/primitives/checkbox.d.ts +4 -0
- package/dist/primitives/checkbox.js +8 -0
- package/dist/primitives/choice-control.d.ts +29 -0
- package/dist/primitives/choice-control.js +131 -0
- package/dist/primitives/dialog.d.ts +29 -0
- package/dist/primitives/dialog.js +25 -0
- package/dist/primitives/dropdown-menu.d.ts +37 -0
- package/dist/primitives/dropdown-menu.js +39 -0
- package/dist/primitives/inline-feedback-action.d.ts +13 -0
- package/dist/primitives/inline-feedback-action.js +6 -0
- package/dist/primitives/menu-select.d.ts +12 -0
- package/dist/primitives/menu-select.js +7 -0
- package/dist/primitives/multi-select.d.ts +18 -0
- package/dist/primitives/multi-select.js +7 -0
- package/dist/primitives/number-input.d.ts +10 -0
- package/dist/primitives/number-input.js +8 -0
- package/dist/primitives/option-pill.d.ts +13 -0
- package/dist/primitives/option-pill.js +50 -0
- package/dist/primitives/tabs.d.ts +7 -0
- package/dist/primitives/tabs.js +12 -0
- package/dist/primitives/textarea.d.ts +4 -0
- package/dist/primitives/textarea.js +8 -0
- package/dist/primitives/tooltip.d.ts +31 -0
- package/dist/primitives/tooltip.js +37 -0
- package/dist/standalone.css +113 -0
- package/dist/structured-data/atomic-value-editor.d.ts +33 -0
- package/dist/structured-data/atomic-value-editor.js +195 -0
- package/dist/structured-data/atomic-value.d.ts +3 -0
- package/dist/structured-data/atomic-value.js +6 -0
- package/dist/structured-data/column-controls.d.ts +21 -0
- package/dist/structured-data/column-controls.js +180 -0
- package/dist/structured-data/copy-json-action.d.ts +10 -0
- package/dist/structured-data/copy-json-action.js +87 -0
- package/dist/structured-data/delete-row-action.d.ts +10 -0
- package/dist/structured-data/delete-row-action.js +37 -0
- package/dist/structured-data/edit-base.d.ts +3 -0
- package/dist/structured-data/edit-base.js +8 -0
- package/dist/structured-data/filter-value.d.ts +2 -0
- package/dist/structured-data/filter-value.js +28 -0
- package/dist/structured-data/row-selection-icon.d.ts +9 -0
- package/dist/structured-data/row-selection-icon.js +6 -0
- package/dist/structured-data/structured-cell-fill-context.d.ts +6 -0
- package/dist/structured-data/structured-cell-fill-context.js +7 -0
- package/dist/structured-data/structured-data-content.d.ts +1 -0
- package/dist/structured-data/structured-data-content.js +1 -0
- package/dist/structured-data/table-types.d.ts +108 -0
- package/dist/structured-data/table-types.js +1 -0
- package/dist/structured-data/use-metadata-persistence.d.ts +15 -0
- package/dist/structured-data/use-metadata-persistence.js +18 -0
- package/dist/structured-data/use-optimistic-text-document.d.ts +22 -0
- package/dist/structured-data/use-optimistic-text-document.js +30 -0
- package/dist/structured-data/value-cell.d.ts +15 -0
- package/dist/structured-data/value-cell.js +81 -0
- package/dist/styles.css +2 -0
- package/dist/surface.d.ts +14 -0
- package/dist/surface.js +73 -0
- package/dist/tabular-data-view.d.ts +5 -0
- package/dist/tabular-data-view.js +493 -0
- package/dist/viewer-state.d.ts +72 -0
- package/dist/viewer-state.js +79 -0
- package/dist/widget-registry.d.ts +60 -0
- package/dist/widget-registry.js +126 -0
- package/dist/widgets/boolean-widget.d.ts +2 -0
- package/dist/widgets/boolean-widget.js +15 -0
- package/dist/widgets/date-picker-widget.d.ts +18 -0
- package/dist/widgets/date-picker-widget.js +265 -0
- package/dist/widgets/dropdown-widget.d.ts +10 -0
- package/dist/widgets/dropdown-widget.js +19 -0
- package/dist/widgets/multi-select-widget.d.ts +11 -0
- package/dist/widgets/multi-select-widget.js +46 -0
- package/dist/widgets/number-widget.d.ts +2 -0
- package/dist/widgets/number-widget.js +19 -0
- package/dist/widgets/read-only-field.d.ts +36 -0
- package/dist/widgets/read-only-field.js +39 -0
- package/dist/widgets/text-widget.d.ts +2 -0
- package/dist/widgets/text-widget.js +19 -0
- package/dist/widgets/textarea-widget.d.ts +2 -0
- package/dist/widgets/textarea-widget.js +34 -0
- package/dist/widgets/types.d.ts +53 -0
- package/dist/widgets/types.js +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { HtmlViewSettings } from './html-view/settings.js';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { useAnchoredPosition } from '../lib/use-anchored-position.js';
|
|
5
|
+
import { useJsonViewsPortalContainer } from '../surface.js';
|
|
6
|
+
import { jsonValueText } from '@script-it/json-views-core';
|
|
7
|
+
import { parseFilterValue } from '../structured-data/filter-value.js';
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { ChevronDown, Columns3, Copy, GripVertical, Plus, Settings2, Table2, Trash2, } from 'lucide-react';
|
|
10
|
+
import { useJsonViewsRegistries } from '../widget-registry.js';
|
|
11
|
+
import { Checkbox } from '../primitives/checkbox.js';
|
|
12
|
+
import { MenuSelect } from '../primitives/menu-select.js';
|
|
13
|
+
import { ConditionalTooltip } from '../primitives/tooltip.js';
|
|
14
|
+
import { cn } from '../lib/cn.js';
|
|
15
|
+
import { useDismiss } from '../lib/use-dismiss.js';
|
|
16
|
+
import { getValueAtPath, getJsonViewViewRows, parseJsonViewPath, VALUE_PATH_MISSING, } from '@script-it/json-views-core';
|
|
17
|
+
const OPERATORS = [
|
|
18
|
+
{ value: 'eq', label: 'is' },
|
|
19
|
+
{ value: 'neq', label: 'is not' },
|
|
20
|
+
{ value: 'in', label: 'is any of' },
|
|
21
|
+
{ value: 'notIn', label: 'is none of' },
|
|
22
|
+
{ value: 'gt', label: 'is greater than' },
|
|
23
|
+
{ value: 'gte', label: 'is at least' },
|
|
24
|
+
{ value: 'lt', label: 'is less than' },
|
|
25
|
+
{ value: 'lte', label: 'is at most' },
|
|
26
|
+
{ value: 'contains', label: 'contains' },
|
|
27
|
+
{ value: 'notContains', label: 'does not contain' },
|
|
28
|
+
{ value: 'isEmpty', label: 'is empty' },
|
|
29
|
+
{ value: 'isNotEmpty', label: 'is not empty' },
|
|
30
|
+
];
|
|
31
|
+
const DATE_OPERATORS = [
|
|
32
|
+
{ value: 'eq', label: 'is' },
|
|
33
|
+
{ value: 'neq', label: 'is not' },
|
|
34
|
+
{ value: 'lt', label: 'is before' },
|
|
35
|
+
{ value: 'lte', label: 'is on or before' },
|
|
36
|
+
{ value: 'gt', label: 'is after' },
|
|
37
|
+
{ value: 'gte', label: 'is on or after' },
|
|
38
|
+
{ value: 'isEmpty', label: 'is empty' },
|
|
39
|
+
{ value: 'isNotEmpty', label: 'is not empty' },
|
|
40
|
+
];
|
|
41
|
+
function operatorsFor(field, types) {
|
|
42
|
+
const defaults = field?.type === 'date' ? DATE_OPERATORS : OPERATORS;
|
|
43
|
+
const declared = field?.type ? types.filterOperators({ type: field.type }) : undefined;
|
|
44
|
+
return declared ? [
|
|
45
|
+
...defaults.filter((item) => declared.includes(item.value)),
|
|
46
|
+
...declared.filter((value) => !defaults.some((item) => item.value === value)).map((value) => ({ value, label: value })),
|
|
47
|
+
] : defaults;
|
|
48
|
+
}
|
|
49
|
+
const INPUT_CLASS = 'h-8 min-w-0 rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring';
|
|
50
|
+
const ICON_BUTTON_CLASS = 'grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]';
|
|
51
|
+
function isRecord(value) {
|
|
52
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
function friendlyLabel(value) {
|
|
55
|
+
const words = value
|
|
56
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
57
|
+
.replace(/[_-]+/g, ' ')
|
|
58
|
+
.trim();
|
|
59
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : value;
|
|
60
|
+
}
|
|
61
|
+
function appendProperty(path, key) {
|
|
62
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key))
|
|
63
|
+
return `${path}.${key}`;
|
|
64
|
+
const escaped = key
|
|
65
|
+
.replace(/\\/g, '\\\\')
|
|
66
|
+
.replace(/'/g, "\\'")
|
|
67
|
+
.replace(/\n/g, '\\n')
|
|
68
|
+
.replace(/\r/g, '\\r')
|
|
69
|
+
.replace(/\t/g, '\\t');
|
|
70
|
+
return `${path}['${escaped}']`;
|
|
71
|
+
}
|
|
72
|
+
function valueKind(values) {
|
|
73
|
+
const kinds = new Set(values.filter((value) => value !== null && value !== undefined).map((value) => (Array.isArray(value) ? 'array' : typeof value === 'object' ? 'object' : typeof value)));
|
|
74
|
+
if (kinds.size !== 1)
|
|
75
|
+
return 'mixed';
|
|
76
|
+
const kind = Array.from(kinds)[0];
|
|
77
|
+
if (kind === 'boolean' || kind === 'number' || kind === 'string' || kind === 'array' || kind === 'object')
|
|
78
|
+
return kind;
|
|
79
|
+
return 'mixed';
|
|
80
|
+
}
|
|
81
|
+
function primitiveOptions(values) {
|
|
82
|
+
const options = new Set();
|
|
83
|
+
values.forEach((value) => {
|
|
84
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
|
85
|
+
options.add(String(value));
|
|
86
|
+
});
|
|
87
|
+
return options.size <= 20 ? Array.from(options) : [];
|
|
88
|
+
}
|
|
89
|
+
function viewFieldKeys(view, source) {
|
|
90
|
+
const prefixLength = view.path.segments.length;
|
|
91
|
+
const samePrefix = view.path.segments.every((segment, index) => {
|
|
92
|
+
const candidate = source.segments[index];
|
|
93
|
+
if (segment.kind !== candidate?.kind)
|
|
94
|
+
return false;
|
|
95
|
+
if (segment.kind === 'property' && candidate.kind === 'property')
|
|
96
|
+
return segment.key === candidate.key;
|
|
97
|
+
if (segment.kind === 'index' && candidate.kind === 'index')
|
|
98
|
+
return segment.index === candidate.index;
|
|
99
|
+
return segment.kind === 'wildcard';
|
|
100
|
+
});
|
|
101
|
+
if (!samePrefix)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (source.segments[prefixLength]?.kind !== 'wildcard')
|
|
104
|
+
return undefined;
|
|
105
|
+
const relative = source.segments.slice(prefixLength + 1);
|
|
106
|
+
if (relative.length === 0 || relative.some((segment) => segment.kind !== 'property'))
|
|
107
|
+
return undefined;
|
|
108
|
+
return relative.map((segment) => segment.kind === 'property' ? segment.key : '');
|
|
109
|
+
}
|
|
110
|
+
function discoverViewFields(view, schema) {
|
|
111
|
+
const rows = getJsonViewViewRows(view);
|
|
112
|
+
const discovered = new Map();
|
|
113
|
+
const visit = (value, keys) => {
|
|
114
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
115
|
+
const childKeys = [...keys, key];
|
|
116
|
+
const path = childKeys.reduce((source, segment) => appendProperty(source, segment), `${view.path.source}[*]`);
|
|
117
|
+
const item = discovered.get(path) ?? {
|
|
118
|
+
labels: childKeys.map(friendlyLabel),
|
|
119
|
+
direct: childKeys.length === 1,
|
|
120
|
+
values: [],
|
|
121
|
+
};
|
|
122
|
+
item.values.push(child);
|
|
123
|
+
discovered.set(path, item);
|
|
124
|
+
if (isRecord(child))
|
|
125
|
+
visit(child, childKeys);
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
rows.forEach((row) => visit(row.value, []));
|
|
129
|
+
const declaredPaths = [
|
|
130
|
+
...(view.columns?.map((column) => column.path) ?? []),
|
|
131
|
+
...(view.filter?.rules.map((rule) => rule.path) ?? []),
|
|
132
|
+
...view.sort.map((sort) => sort.path),
|
|
133
|
+
...(view.groupBy ? [view.groupBy] : []),
|
|
134
|
+
...(view.orderPath ? [view.orderPath] : []),
|
|
135
|
+
];
|
|
136
|
+
declaredPaths.forEach((path) => {
|
|
137
|
+
if (discovered.has(path.source))
|
|
138
|
+
return;
|
|
139
|
+
const keys = viewFieldKeys(view, path);
|
|
140
|
+
if (!keys)
|
|
141
|
+
return;
|
|
142
|
+
discovered.set(path.source, {
|
|
143
|
+
labels: keys.map(friendlyLabel),
|
|
144
|
+
direct: keys.length === 1,
|
|
145
|
+
values: [],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
schema.forEach((entry) => {
|
|
149
|
+
if (discovered.has(entry.path.source))
|
|
150
|
+
return;
|
|
151
|
+
const keys = viewFieldKeys(view, entry.path);
|
|
152
|
+
if (!keys)
|
|
153
|
+
return;
|
|
154
|
+
discovered.set(entry.path.source, {
|
|
155
|
+
labels: keys.map(friendlyLabel),
|
|
156
|
+
direct: keys.length === 1,
|
|
157
|
+
values: [],
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
return Array.from(discovered.entries()).map(([path, item]) => ({
|
|
161
|
+
path,
|
|
162
|
+
direct: item.direct,
|
|
163
|
+
defaultLabel: item.labels.join(' › '),
|
|
164
|
+
kind: valueKind(item.values),
|
|
165
|
+
values: primitiveOptions(item.values),
|
|
166
|
+
type: schema.find((entry) => entry.path.source === path)?.descriptor.type,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
function rawColumns(raw) {
|
|
170
|
+
if (!Array.isArray(raw.columns) || raw.columns.length === 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
return raw.columns.flatMap((item) => (isRecord(item) && typeof item.label === 'string' && typeof item.path === 'string'
|
|
173
|
+
? [{ label: item.label, path: item.path }]
|
|
174
|
+
: []));
|
|
175
|
+
}
|
|
176
|
+
function propertyDrafts(raw, fields) {
|
|
177
|
+
const columns = rawColumns(raw);
|
|
178
|
+
const byPath = new Map(fields.map((field) => [field.path, field]));
|
|
179
|
+
const selected = columns
|
|
180
|
+
? columns.flatMap((column) => {
|
|
181
|
+
const field = byPath.get(column.path);
|
|
182
|
+
return field ? [{ ...field, label: column.label, visible: true }] : [];
|
|
183
|
+
})
|
|
184
|
+
: fields.filter((field) => field.direct).map((field) => ({ ...field, label: field.defaultLabel, visible: true }));
|
|
185
|
+
const selectedPaths = new Set(selected.map((field) => field.path));
|
|
186
|
+
return [
|
|
187
|
+
...selected,
|
|
188
|
+
...fields.filter((field) => !selectedPaths.has(field.path)).map((field) => ({ ...field, label: field.defaultLabel, visible: false })),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
function valueText(value, operator) {
|
|
192
|
+
if ((operator === 'in' || operator === 'notIn') && Array.isArray(value))
|
|
193
|
+
return value.map(jsonValueText).join(', ');
|
|
194
|
+
if (typeof value === 'string')
|
|
195
|
+
return value;
|
|
196
|
+
if (value === undefined)
|
|
197
|
+
return '';
|
|
198
|
+
try {
|
|
199
|
+
return JSON.stringify(value);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return jsonValueText(value);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function filtersFrom(raw) {
|
|
206
|
+
if (!isRecord(raw.filter))
|
|
207
|
+
return { match: 'all', rules: [] };
|
|
208
|
+
const match = raw.filter.match === 'any' ? 'any' : 'all';
|
|
209
|
+
const rules = Array.isArray(raw.filter.rules) ? raw.filter.rules.flatMap((item) => {
|
|
210
|
+
if (!isRecord(item) || typeof item.path !== 'string' || typeof item.operator !== 'string')
|
|
211
|
+
return [];
|
|
212
|
+
const operator = item.operator;
|
|
213
|
+
return [{ path: item.path, operator, value: valueText(item.value, operator) }];
|
|
214
|
+
}) : [];
|
|
215
|
+
return { match, rules };
|
|
216
|
+
}
|
|
217
|
+
function sortsFrom(raw) {
|
|
218
|
+
return Array.isArray(raw.sort) ? raw.sort.flatMap((item) => (isRecord(item) && typeof item.path === 'string' && (item.direction === 'asc' || item.direction === 'desc')
|
|
219
|
+
? [{ path: item.path, direction: item.direction }]
|
|
220
|
+
: [])) : [];
|
|
221
|
+
}
|
|
222
|
+
function filterValue(filter, fields) {
|
|
223
|
+
const field = fields.find((candidate) => candidate.path === filter.path);
|
|
224
|
+
if (filter.operator === 'in' || filter.operator === 'notIn') {
|
|
225
|
+
return filter.value.split(',').map((value) => parseFilterValue(value.trim(), field?.kind)).filter((value) => value !== '');
|
|
226
|
+
}
|
|
227
|
+
return parseFilterValue(filter.value, field?.kind);
|
|
228
|
+
}
|
|
229
|
+
function buildViewDraft({ configurable, display, fields, filters, groupBy, match, name, properties, rawView, sorts, }) {
|
|
230
|
+
const visibleProperties = properties.filter((property) => property.visible);
|
|
231
|
+
if (!name.trim())
|
|
232
|
+
return { error: 'Give the view a name' };
|
|
233
|
+
if (configurable && visibleProperties.length === 0)
|
|
234
|
+
return { error: 'Show at least one property' };
|
|
235
|
+
if (configurable && display === 'kanban' && !groupBy)
|
|
236
|
+
return { error: 'Choose a property to group the Kanban by' };
|
|
237
|
+
const next = { ...rawView, name: name.trim() };
|
|
238
|
+
if (configurable) {
|
|
239
|
+
const inferredProperties = fields.filter((field) => field.direct);
|
|
240
|
+
const stillInferred = rawColumns(rawView) === undefined
|
|
241
|
+
&& visibleProperties.length === inferredProperties.length
|
|
242
|
+
&& visibleProperties.every((property, index) => (property.path === inferredProperties[index]?.path && property.label === property.defaultLabel));
|
|
243
|
+
if (stillInferred)
|
|
244
|
+
delete next.columns;
|
|
245
|
+
else
|
|
246
|
+
next.columns = visibleProperties.map((property) => ({ label: property.label.trim() || property.defaultLabel, path: property.path }));
|
|
247
|
+
if (filters.length > 0) {
|
|
248
|
+
next.filter = {
|
|
249
|
+
...(match === 'any' ? { match } : {}),
|
|
250
|
+
rules: filters.map((filter) => ({
|
|
251
|
+
path: filter.path,
|
|
252
|
+
operator: filter.operator,
|
|
253
|
+
...((filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty') ? {} : { value: filterValue(filter, fields) }),
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
else
|
|
258
|
+
delete next.filter;
|
|
259
|
+
if (sorts.length > 0)
|
|
260
|
+
next.sort = sorts;
|
|
261
|
+
else
|
|
262
|
+
delete next.sort;
|
|
263
|
+
if (display === 'kanban') {
|
|
264
|
+
next.display = 'kanban';
|
|
265
|
+
if (rawView.groupBy !== groupBy)
|
|
266
|
+
delete next.groupOrder;
|
|
267
|
+
next.groupBy = groupBy;
|
|
268
|
+
if (next.orderPath === groupBy)
|
|
269
|
+
delete next.orderPath;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
delete next.display;
|
|
273
|
+
delete next.groupBy;
|
|
274
|
+
delete next.groupOrder;
|
|
275
|
+
delete next.orderPath;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
delete next.columns;
|
|
280
|
+
delete next.filter;
|
|
281
|
+
delete next.sort;
|
|
282
|
+
delete next.display;
|
|
283
|
+
delete next.groupBy;
|
|
284
|
+
delete next.groupOrder;
|
|
285
|
+
delete next.orderPath;
|
|
286
|
+
}
|
|
287
|
+
return { value: next };
|
|
288
|
+
}
|
|
289
|
+
function summaryForFilter(filters, fields, types) {
|
|
290
|
+
if (filters.length === 0)
|
|
291
|
+
return 'None';
|
|
292
|
+
const filter = filters[0];
|
|
293
|
+
const field = fields.find((candidate) => candidate.path === filter.path)?.defaultLabel ?? 'Property';
|
|
294
|
+
const operator = operatorsFor(fields.find((candidate) => candidate.path === filter.path), types).find((candidate) => candidate.value === filter.operator)?.label ?? filter.operator;
|
|
295
|
+
const rule = `${field} ${operator}${filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty' ? '' : ` ${filter.value}`}`;
|
|
296
|
+
return filters.length === 1 ? rule : `${rule} +${filters.length - 1}`;
|
|
297
|
+
}
|
|
298
|
+
function OptionsSection({ children, defaultOpen = false, summary, title, }) {
|
|
299
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
300
|
+
return (_jsxs("details", { className: "group border-t border-border", open: open, onToggle: (event) => setOpen(event.currentTarget.open), children: [_jsxs("summary", { className: "flex min-h-10 cursor-pointer list-none items-center gap-2 rounded-md px-1 text-xs hover:bg-accent", children: [_jsx("span", { className: "w-20 shrink-0 font-medium text-foreground", children: title }), _jsx("span", { className: "min-w-0 flex-1 truncate text-right text-muted-foreground", children: summary }), _jsx(ChevronDown, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-open:rotate-180" })] }), _jsx("div", { className: "px-1 pb-3 pt-1", children: children })] }));
|
|
301
|
+
}
|
|
302
|
+
function FieldSelect({ ariaLabel, fields, includeEmpty = false, onChange, value, }) {
|
|
303
|
+
return (_jsx(MenuSelect, { ariaLabel: ariaLabel, emptyOptionLabel: includeEmpty ? 'Select a property' : undefined, options: fields.map((field) => ({ value: field.path, label: field.defaultLabel })), value: value, onChange: onChange }));
|
|
304
|
+
}
|
|
305
|
+
function BuiltInViewSettings({ disabled = false, hasUnsavedChanges = false, onDelete, onDuplicate, onPreview, onSave, rawView, saveMode = 'automatic', saveUnavailableReason, schema, unavailableReason, view, }) {
|
|
306
|
+
const { types } = useJsonViewsRegistries();
|
|
307
|
+
const fields = useMemo(() => discoverViewFields(view, schema), [schema, view]);
|
|
308
|
+
const [open, setOpen] = useState(false);
|
|
309
|
+
const [name, setName] = useState(view.name);
|
|
310
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
311
|
+
const [properties, setProperties] = useState(() => propertyDrafts(rawView, fields));
|
|
312
|
+
const initialFilter = filtersFrom(rawView);
|
|
313
|
+
const [match, setMatch] = useState(initialFilter.match);
|
|
314
|
+
const [filters, setFilters] = useState(initialFilter.rules);
|
|
315
|
+
const [sorts, setSorts] = useState(() => sortsFrom(rawView));
|
|
316
|
+
const [display, setDisplay] = useState(view.display === 'html' ? 'adaptive' : view.display);
|
|
317
|
+
const [groupBy, setGroupBy] = useState(typeof rawView.groupBy === 'string' ? rawView.groupBy : '');
|
|
318
|
+
const [error, setError] = useState();
|
|
319
|
+
const [saveState, setSaveState] = useState('idle');
|
|
320
|
+
const triggerRef = useRef(null);
|
|
321
|
+
const popoverRef = useRef(null);
|
|
322
|
+
const portalContainer = useJsonViewsPortalContainer();
|
|
323
|
+
const position = useAnchoredPosition(open, triggerRef, popoverRef, 'bottom', 'end');
|
|
324
|
+
const lastSavedRef = useRef(JSON.stringify(rawView));
|
|
325
|
+
const pendingKeyRef = useRef(undefined);
|
|
326
|
+
const skipNextAutomaticSaveRef = useRef(false);
|
|
327
|
+
const saveChainRef = useRef(Promise.resolve());
|
|
328
|
+
const saveRevisionRef = useRef(0);
|
|
329
|
+
const groupFields = fields.filter((field) => field.kind !== 'array' && field.kind !== 'object');
|
|
330
|
+
const visibleProperties = properties.filter((property) => property.visible);
|
|
331
|
+
const configurable = isRecordCollectionValue(view.value);
|
|
332
|
+
const draft = useMemo(() => {
|
|
333
|
+
try {
|
|
334
|
+
return buildViewDraft({ configurable, display, fields, filters, groupBy, match, name, properties, rawView, sorts });
|
|
335
|
+
}
|
|
336
|
+
catch (cause) {
|
|
337
|
+
return { error: cause instanceof Error ? cause.message : 'Invalid filter value', value: undefined };
|
|
338
|
+
}
|
|
339
|
+
}, [configurable, display, fields, filters, groupBy, match, name, properties, rawView, sorts]);
|
|
340
|
+
const draftKey = draft.value ? JSON.stringify(draft.value) : undefined;
|
|
341
|
+
const reset = () => {
|
|
342
|
+
const nextFilter = filtersFrom(rawView);
|
|
343
|
+
setName(view.name);
|
|
344
|
+
setConfirmDelete(false);
|
|
345
|
+
setProperties(propertyDrafts(rawView, fields));
|
|
346
|
+
setMatch(nextFilter.match);
|
|
347
|
+
setFilters(nextFilter.rules);
|
|
348
|
+
setSorts(sortsFrom(rawView));
|
|
349
|
+
setDisplay(view.display === 'html' ? 'adaptive' : view.display);
|
|
350
|
+
setGroupBy(typeof rawView.groupBy === 'string' ? rawView.groupBy : '');
|
|
351
|
+
setError(undefined);
|
|
352
|
+
setSaveState('idle');
|
|
353
|
+
};
|
|
354
|
+
const openSettings = () => {
|
|
355
|
+
const sourceKey = JSON.stringify(rawView);
|
|
356
|
+
reset();
|
|
357
|
+
lastSavedRef.current = hasUnsavedChanges ? undefined : sourceKey;
|
|
358
|
+
pendingKeyRef.current = undefined;
|
|
359
|
+
skipNextAutomaticSaveRef.current = true;
|
|
360
|
+
setOpen(true);
|
|
361
|
+
};
|
|
362
|
+
const closeWithoutSaving = () => {
|
|
363
|
+
onPreview?.(undefined);
|
|
364
|
+
reset();
|
|
365
|
+
setOpen(false);
|
|
366
|
+
};
|
|
367
|
+
const persistDraft = async (value, closeAfter = false) => {
|
|
368
|
+
const key = JSON.stringify(value);
|
|
369
|
+
if (saveUnavailableReason) {
|
|
370
|
+
setError(saveUnavailableReason);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (key === lastSavedRef.current || key === pendingKeyRef.current) {
|
|
374
|
+
if (closeAfter) {
|
|
375
|
+
try {
|
|
376
|
+
await saveChainRef.current;
|
|
377
|
+
onPreview?.(undefined);
|
|
378
|
+
setOpen(false);
|
|
379
|
+
}
|
|
380
|
+
catch { /* The original save reports the error and keeps this open. */ }
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const revision = ++saveRevisionRef.current;
|
|
385
|
+
pendingKeyRef.current = key;
|
|
386
|
+
setError(undefined);
|
|
387
|
+
setSaveState('saving');
|
|
388
|
+
const operation = saveChainRef.current.catch(() => { }).then(() => onSave(value));
|
|
389
|
+
saveChainRef.current = operation;
|
|
390
|
+
try {
|
|
391
|
+
await operation;
|
|
392
|
+
lastSavedRef.current = key;
|
|
393
|
+
if (pendingKeyRef.current === key)
|
|
394
|
+
pendingKeyRef.current = undefined;
|
|
395
|
+
if (revision === saveRevisionRef.current)
|
|
396
|
+
setSaveState('saved');
|
|
397
|
+
if (closeAfter) {
|
|
398
|
+
onPreview?.(undefined);
|
|
399
|
+
setOpen(false);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (saveError) {
|
|
403
|
+
if (pendingKeyRef.current === key)
|
|
404
|
+
pendingKeyRef.current = undefined;
|
|
405
|
+
if (revision === saveRevisionRef.current) {
|
|
406
|
+
setSaveState('idle');
|
|
407
|
+
setError(saveError instanceof Error ? saveError.message : 'Could not save this view');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const finishSettings = () => {
|
|
412
|
+
if (saveMode === 'explicit') {
|
|
413
|
+
setOpen(false);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (draft.value && draftKey !== lastSavedRef.current)
|
|
417
|
+
void persistDraft(draft.value, true);
|
|
418
|
+
else {
|
|
419
|
+
onPreview?.(undefined);
|
|
420
|
+
setOpen(false);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
useDismiss({
|
|
424
|
+
enabled: open,
|
|
425
|
+
onDismiss: finishSettings,
|
|
426
|
+
insideRefs: [triggerRef, popoverRef],
|
|
427
|
+
ignoreSelectors: ['[data-jsonviews-metadata-persistence-dialog]'],
|
|
428
|
+
});
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!open)
|
|
431
|
+
return;
|
|
432
|
+
if (skipNextAutomaticSaveRef.current) {
|
|
433
|
+
skipNextAutomaticSaveRef.current = false;
|
|
434
|
+
if (!hasUnsavedChanges && draftKey)
|
|
435
|
+
lastSavedRef.current = draftKey;
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
onPreview?.(draftKey === lastSavedRef.current ? undefined : draft.value);
|
|
439
|
+
if (disabled)
|
|
440
|
+
return;
|
|
441
|
+
if (saveMode !== 'automatic' || !draft.value || !draftKey || draftKey === lastSavedRef.current || draftKey === pendingKeyRef.current)
|
|
442
|
+
return;
|
|
443
|
+
const timer = window.setTimeout(() => { void persistDraft(draft.value); }, 300);
|
|
444
|
+
return () => window.clearTimeout(timer);
|
|
445
|
+
}, [disabled, draft.value, draftKey, hasUnsavedChanges, onPreview, open, saveMode]);
|
|
446
|
+
const toggleProperty = (path, visible) => setProperties((current) => {
|
|
447
|
+
const changed = current.map((property) => property.path === path ? { ...property, visible } : property);
|
|
448
|
+
return [...changed.filter((property) => property.visible), ...changed.filter((property) => !property.visible)];
|
|
449
|
+
});
|
|
450
|
+
const moveProperty = (sourcePath, targetPath) => setProperties((current) => {
|
|
451
|
+
const visible = current.filter((property) => property.visible);
|
|
452
|
+
const sourceIndex = visible.findIndex((property) => property.path === sourcePath);
|
|
453
|
+
const targetIndex = visible.findIndex((property) => property.path === targetPath);
|
|
454
|
+
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex)
|
|
455
|
+
return current;
|
|
456
|
+
const [moved] = visible.splice(sourceIndex, 1);
|
|
457
|
+
visible.splice(targetIndex, 0, moved);
|
|
458
|
+
return [...visible, ...current.filter((property) => !property.visible)];
|
|
459
|
+
});
|
|
460
|
+
const save = async () => {
|
|
461
|
+
if (!draft.value) {
|
|
462
|
+
setError(draft.error ?? 'This view is not valid');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
await persistDraft(draft.value, true);
|
|
466
|
+
};
|
|
467
|
+
const runAction = async (action) => {
|
|
468
|
+
setError(undefined);
|
|
469
|
+
try {
|
|
470
|
+
await action();
|
|
471
|
+
setOpen(false);
|
|
472
|
+
}
|
|
473
|
+
catch (actionError) {
|
|
474
|
+
setError(actionError instanceof Error ? actionError.message : 'Could not update this view');
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const firstField = fields[0];
|
|
478
|
+
return (_jsxs("div", { className: "relative shrink-0", children: [_jsx(ConditionalTooltip, { enabled: unavailableReason !== undefined, label: unavailableReason ?? '', side: "bottom", align: "end", delayDuration: 200, children: _jsx("span", { className: "inline-flex", tabIndex: unavailableReason ? 0 : undefined, children: _jsx("button", { ref: triggerRef, type: "button", "data-id": "jsonView-edit-view", "aria-label": "View options", disabled: disabled || unavailableReason !== undefined, className: ICON_BUTTON_CLASS, onClick: openSettings, children: _jsx(Settings2, { className: "h-3.5 w-3.5" }) }) }) }), open && typeof document !== 'undefined' && createPortal(_jsxs("section", { ref: popoverRef, "data-id": "view-settings", style: position, className: "z-[100] w-[min(26rem,calc(100vw-2rem))] overflow-y-auto rounded-lg border border-border bg-card p-2 text-xs text-foreground shadow-lg [transform-origin:top_right]", children: [_jsx("header", { className: "flex min-h-10 items-center gap-2 px-1 pb-2", children: _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-medium", children: "View options" }), _jsxs("div", { className: "mt-1 flex min-w-0 items-center gap-0.5", children: [_jsx("input", { "aria-label": "View name", className: "h-7 min-w-0 flex-1 rounded-sm bg-transparent px-1 text-xs text-muted-foreground outline-none hover:bg-accent focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring", value: name, onChange: (event) => setName(event.target.value) }), onDuplicate && _jsx("button", { type: "button", "aria-label": "Duplicate view", title: "Duplicate view", disabled: disabled, className: ICON_BUTTON_CLASS, onClick: () => { void runAction(onDuplicate); }, children: _jsx(Copy, { className: "h-3.5 w-3.5" }) }), onDelete && _jsx("button", { type: "button", "aria-label": "Delete view", title: "Delete view", disabled: disabled, className: `${ICON_BUTTON_CLASS} text-destructive hover:bg-destructive/10 hover:text-destructive`, onClick: () => setConfirmDelete(true), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] })] }) }), confirmDelete && onDelete && (_jsxs("div", { className: "mb-2 flex items-center gap-2 rounded-md bg-destructive/10 px-2 py-2 text-destructive", children: [_jsx("span", { className: "min-w-0 flex-1", children: "Delete this view?" }), _jsx("button", { type: "button", className: "rounded-md px-2 py-1 hover:bg-accent", onClick: () => setConfirmDelete(false), children: "Cancel" }), _jsx("button", { type: "button", "data-id": "jsonView-confirm-delete-view", disabled: disabled, className: "rounded-md bg-destructive px-2 py-1 text-destructive-foreground disabled:opacity-50", onClick: () => { void runAction(onDelete); }, children: "Delete" })] })), configurable && _jsxs(_Fragment, { children: [_jsxs(OptionsSection, { title: "Layout", summary: display === 'kanban' ? 'Kanban' : 'Table', defaultOpen: true, children: [_jsxs("div", { className: "grid grid-cols-2 gap-1 rounded-md bg-muted p-1", children: [_jsxs("button", { type: "button", "data-id": "jsonView-layout-table", className: `flex h-8 items-center justify-center gap-1.5 rounded-md ${display === 'adaptive' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`, onClick: () => setDisplay('adaptive'), children: [_jsx(Table2, { className: "h-3.5 w-3.5" }), " Table"] }), _jsxs("button", { type: "button", "data-id": "jsonView-layout-kanban", className: `flex h-8 items-center justify-center gap-1.5 rounded-md ${display === 'kanban' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`, onClick: () => setDisplay('kanban'), children: [_jsx(Columns3, { className: "h-3.5 w-3.5" }), " Kanban"] })] }), display === 'kanban' && (_jsxs("label", { className: "mt-2 grid gap-1 text-muted-foreground", children: ["Group by", _jsx(FieldSelect, { ariaLabel: "Group by", fields: groupFields, includeEmpty: true, value: groupBy, onChange: setGroupBy })] }))] }), _jsx(OptionsSection, { title: "Properties", summary: `${visibleProperties.length} shown`, children: _jsx("div", { className: "max-h-56 overflow-y-auto rounded-md border border-border", children: properties.map((property) => (_jsxs("div", { "data-id": "jsonView-property-option", "data-field-path": property.path, draggable: property.visible, className: "flex min-h-9 items-center gap-2 border-b border-border px-2 last:border-b-0", onDragStart: (event) => event.dataTransfer.setData('application/x-jsonView-view-property', property.path), onDragOver: (event) => { if (property.visible)
|
|
479
|
+
event.preventDefault(); }, onDrop: (event) => {
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
moveProperty(event.dataTransfer.getData('application/x-jsonView-view-property'), property.path);
|
|
482
|
+
}, children: [_jsx("button", { type: "button", "aria-label": `Reorder ${property.defaultLabel}`, disabled: !property.visible, className: "cursor-grab text-muted-foreground disabled:cursor-default disabled:opacity-25", onKeyDown: (event) => {
|
|
483
|
+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')
|
|
484
|
+
return;
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
const index = visibleProperties.findIndex((item) => item.path === property.path);
|
|
487
|
+
const target = visibleProperties[index + (event.key === 'ArrowUp' ? -1 : 1)];
|
|
488
|
+
if (target)
|
|
489
|
+
moveProperty(property.path, target.path);
|
|
490
|
+
}, children: _jsx(GripVertical, { className: "h-3.5 w-3.5" }) }), _jsx(Checkbox, { "aria-label": `Show ${property.defaultLabel}`, checked: property.visible, onCheckedChange: (checked) => toggleProperty(property.path, checked === true) }), property.visible
|
|
491
|
+
? _jsx("input", { "aria-label": `${property.defaultLabel} label`, className: "h-8 min-w-0 flex-1 bg-transparent px-1 text-xs outline-none focus-visible:rounded-sm focus-visible:ring-1 focus-visible:ring-ring", value: property.label, onChange: (event) => setProperties((current) => current.map((item) => item.path === property.path ? { ...item, label: event.target.value } : item)) })
|
|
492
|
+
: _jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground", children: property.defaultLabel })] }, property.path))) }) }), _jsxs(OptionsSection, { title: "Filter", summary: summaryForFilter(filters, fields, types), children: [filters.length > 1 && (_jsxs("label", { className: "mb-2 flex items-center justify-between gap-2 text-muted-foreground", children: ["Match", _jsx(MenuSelect, { ariaLabel: "Filter match", options: [{ value: 'all', label: 'All rules' }, { value: 'any', label: 'Any rule' }], value: match, onChange: (value) => setMatch(value === 'any' ? 'any' : 'all') })] })), _jsx("div", { className: "grid gap-2", children: filters.map((filter, index) => {
|
|
493
|
+
const field = fields.find((candidate) => candidate.path === filter.path);
|
|
494
|
+
const hidesValue = filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty';
|
|
495
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)_minmax(6.5rem,0.8fr)_auto] gap-1", children: [_jsx(FieldSelect, { ariaLabel: `Filter ${index + 1} property`, fields: fields, value: filter.path, onChange: (path) => setFilters((current) => current.map((item, itemIndex) => {
|
|
496
|
+
if (itemIndex !== index)
|
|
497
|
+
return item;
|
|
498
|
+
const nextField = fields.find((candidate) => candidate.path === path);
|
|
499
|
+
const allowed = operatorsFor(nextField, types);
|
|
500
|
+
return {
|
|
501
|
+
...item,
|
|
502
|
+
path,
|
|
503
|
+
operator: allowed.some((operator) => operator.value === item.operator) ? item.operator : 'eq',
|
|
504
|
+
value: '',
|
|
505
|
+
};
|
|
506
|
+
})) }), _jsx(MenuSelect, { ariaLabel: `Filter ${index + 1} condition`, options: operatorsFor(field, types).map((operator) => ({ value: operator.value, label: operator.label })), value: filter.operator, onChange: (value) => setFilters((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, operator: value } : item)) }), _jsx("button", { type: "button", "aria-label": "Remove filter", className: ICON_BUTTON_CLASS, onClick: () => setFilters((current) => current.filter((_, itemIndex) => itemIndex !== index)), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }), !hidesValue && (field?.values.length
|
|
507
|
+
? _jsx(MenuSelect, { ariaLabel: `Filter ${index + 1} value`, className: "col-span-2", emptyOptionLabel: "Select a value", options: field.values.map((value) => ({ value, label: value })), value: filter.value, onChange: (value) => setFilters((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value } : item)) })
|
|
508
|
+
: _jsx("input", { "aria-label": `Filter ${index + 1} value`, className: `${INPUT_CLASS} col-span-2`, value: filter.value, placeholder: filter.operator === 'in' || filter.operator === 'notIn' ? 'Comma-separated values' : 'Value', onChange: (event) => setFilters((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item)) }))] }, index));
|
|
509
|
+
}) }), _jsxs("button", { type: "button", "data-id": "jsonView-add-filter", disabled: !firstField, className: "mt-2 flex h-7 items-center gap-1 rounded-md px-2 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50", onClick: () => firstField && setFilters((current) => [...current, { path: firstField.path, operator: 'eq', value: firstField.values[0] ?? '' }]), children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " Add filter"] })] }), _jsxs(OptionsSection, { title: "Sort", summary: sorts.length === 0 ? 'None' : `${fields.find((field) => field.path === sorts[0].path)?.defaultLabel ?? 'Property'} ${sorts[0].direction === 'desc' ? 'descending' : 'ascending'}${sorts.length > 1 ? ` +${sorts.length - 1}` : ''}`, children: [_jsx("div", { className: "grid gap-2", children: sorts.map((sort, index) => (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)_7.5rem_auto] gap-1", children: [_jsx(FieldSelect, { ariaLabel: `Sort ${index + 1} property`, fields: fields, value: sort.path, onChange: (path) => setSorts((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, path } : item)) }), _jsx(MenuSelect, { ariaLabel: `Sort ${index + 1} direction`, options: [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], value: sort.direction, onChange: (value) => setSorts((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, direction: value === 'desc' ? 'desc' : 'asc' } : item)) }), _jsx("button", { type: "button", "aria-label": "Remove sort", className: ICON_BUTTON_CLASS, onClick: () => setSorts((current) => current.filter((_, itemIndex) => itemIndex !== index)), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }, index))) }), _jsxs("button", { type: "button", "data-id": "jsonView-add-sort", disabled: !firstField, className: "mt-2 flex h-7 items-center gap-1 rounded-md px-2 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50", onClick: () => firstField && setSorts((current) => [...current, { path: firstField.path, direction: 'asc' }]), children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " Add sort"] })] })] }), (error || draft.error) && _jsx("p", { role: "alert", className: "px-1 py-2 text-destructive", children: error || draft.error }), saveMode === 'explicit' && (_jsxs("footer", { className: "flex justify-end gap-1 border-t border-border px-1 pt-2", children: [_jsx("button", { type: "button", className: "h-8 rounded-md px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground", onClick: closeWithoutSaving, children: "Cancel" }), _jsx(ConditionalTooltip, { enabled: saveUnavailableReason !== undefined, label: saveUnavailableReason ?? '', side: "bottom", align: "end", delayDuration: 200, children: _jsx("span", { className: "inline-flex", tabIndex: saveUnavailableReason ? 0 : undefined, children: _jsx("button", { type: "button", "data-id": "jsonView-save-view", disabled: disabled || saveUnavailableReason !== undefined || saveState === 'saving', className: "h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50", onClick: () => { void save(); }, children: "Save view" }) }) })] }))] }), portalContainer ?? document.body)] }));
|
|
510
|
+
}
|
|
511
|
+
function collectionPath(path) {
|
|
512
|
+
return path.reduce((source, segment) => typeof segment === 'number' ? `${source}[${segment}]` : appendProperty(source, segment), '$');
|
|
513
|
+
}
|
|
514
|
+
function isRecordCollectionValue(value) {
|
|
515
|
+
if (Array.isArray(value))
|
|
516
|
+
return value.every(isRecord);
|
|
517
|
+
return isRecord(value) && Object.values(value).every(isRecord);
|
|
518
|
+
}
|
|
519
|
+
function discoverViewPaths(root) {
|
|
520
|
+
if (!isRecord(root) && !Array.isArray(root))
|
|
521
|
+
return [];
|
|
522
|
+
const options = [];
|
|
523
|
+
const visit = (value, path) => {
|
|
524
|
+
options.push({
|
|
525
|
+
path: collectionPath(path),
|
|
526
|
+
label: path.length === 0 ? (Array.isArray(root) ? 'Data' : 'Root') : path.map((segment) => typeof segment === 'number' ? String(segment + 1) : friendlyLabel(segment)).join(' › '),
|
|
527
|
+
recordCollection: isRecordCollectionValue(value),
|
|
528
|
+
});
|
|
529
|
+
if (Array.isArray(value)) {
|
|
530
|
+
value.forEach((child, index) => visit(child, [...path, index]));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (!isRecord(value))
|
|
534
|
+
return;
|
|
535
|
+
Object.entries(value)
|
|
536
|
+
.filter(([key]) => key !== '$jsonviews')
|
|
537
|
+
.forEach(([key, child]) => visit(child, [...path, key]));
|
|
538
|
+
};
|
|
539
|
+
visit(root, []);
|
|
540
|
+
return options;
|
|
541
|
+
}
|
|
542
|
+
function collectionFields(root, option, schema) {
|
|
543
|
+
if (!option)
|
|
544
|
+
return [];
|
|
545
|
+
const path = parseJsonViewPath(option.path, { root: '$' });
|
|
546
|
+
const sourcePath = [];
|
|
547
|
+
path.segments.forEach((segment) => {
|
|
548
|
+
if (segment.kind === 'property')
|
|
549
|
+
sourcePath.push(segment.key);
|
|
550
|
+
else if (segment.kind === 'index')
|
|
551
|
+
sourcePath.push(segment.index);
|
|
552
|
+
});
|
|
553
|
+
const value = getValueAtPath(root, sourcePath);
|
|
554
|
+
if (value === VALUE_PATH_MISSING)
|
|
555
|
+
return [];
|
|
556
|
+
const view = {
|
|
557
|
+
id: '__new_view__',
|
|
558
|
+
name: option.label,
|
|
559
|
+
path,
|
|
560
|
+
sourcePath,
|
|
561
|
+
value,
|
|
562
|
+
display: 'adaptive',
|
|
563
|
+
sort: [],
|
|
564
|
+
declarationIndex: -1,
|
|
565
|
+
metadataPath: [],
|
|
566
|
+
};
|
|
567
|
+
return discoverViewFields(view, schema);
|
|
568
|
+
}
|
|
569
|
+
export function JsonViewAddView({ align = 'start', currentPath, disabled = false, onAdd, root, schema, unavailableReason: unavailableReasonOverride, }) {
|
|
570
|
+
const [open, setOpen] = useState(false);
|
|
571
|
+
const [name, setName] = useState('');
|
|
572
|
+
const [display, setDisplay] = useState('table');
|
|
573
|
+
const [groupBy, setGroupBy] = useState('');
|
|
574
|
+
const [saving, setSaving] = useState(false);
|
|
575
|
+
const [error, setError] = useState();
|
|
576
|
+
const triggerRef = useRef(null);
|
|
577
|
+
const popoverRef = useRef(null);
|
|
578
|
+
const portalContainer = useJsonViewsPortalContainer();
|
|
579
|
+
const position = useAnchoredPosition(open, triggerRef, popoverRef, 'bottom', align);
|
|
580
|
+
const options = useMemo(() => discoverViewPaths(root), [root]);
|
|
581
|
+
const selectedOption = options.find((option) => option.path === currentPath)
|
|
582
|
+
?? (currentPath === undefined ? options[0] : undefined);
|
|
583
|
+
const unavailableReason = unavailableReasonOverride
|
|
584
|
+
?? (selectedOption ? undefined : 'This location is not document data that a view can target.');
|
|
585
|
+
const fields = useMemo(() => collectionFields(root, selectedOption, schema), [root, schema, selectedOption]);
|
|
586
|
+
const groupFields = fields.filter((field) => field.kind !== 'array' && field.kind !== 'object');
|
|
587
|
+
const preferredGroup = groupFields.find((field) => field.type === 'select') ?? groupFields[0];
|
|
588
|
+
const reset = () => {
|
|
589
|
+
setName(selectedOption?.label ?? '');
|
|
590
|
+
setDisplay('table');
|
|
591
|
+
setGroupBy('');
|
|
592
|
+
setSaving(false);
|
|
593
|
+
setError(undefined);
|
|
594
|
+
};
|
|
595
|
+
const close = () => {
|
|
596
|
+
setOpen(false);
|
|
597
|
+
reset();
|
|
598
|
+
};
|
|
599
|
+
// A host conversion dialog lives outside this popover. Keep the draft open
|
|
600
|
+
// while its save promise is pending so cancelling conversion is retryable.
|
|
601
|
+
useDismiss({
|
|
602
|
+
enabled: open && !saving,
|
|
603
|
+
onDismiss: close,
|
|
604
|
+
insideRefs: [triggerRef, popoverRef],
|
|
605
|
+
ignoreSelectors: ['[data-jsonviews-metadata-persistence-dialog]'],
|
|
606
|
+
});
|
|
607
|
+
const add = async () => {
|
|
608
|
+
if (!selectedOption) {
|
|
609
|
+
setError('Choose a path');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (!name.trim()) {
|
|
613
|
+
setError('Give the view a name');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (selectedOption.recordCollection && display === 'kanban' && !groupBy) {
|
|
617
|
+
setError('Choose a property to group the Kanban by');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
setError(undefined);
|
|
621
|
+
setSaving(true);
|
|
622
|
+
try {
|
|
623
|
+
await onAdd({
|
|
624
|
+
...selectedOption,
|
|
625
|
+
name: name.trim(),
|
|
626
|
+
display: selectedOption.recordCollection ? display : 'table',
|
|
627
|
+
...(selectedOption.recordCollection && display === 'kanban' ? { groupBy } : {}),
|
|
628
|
+
});
|
|
629
|
+
setOpen(false);
|
|
630
|
+
}
|
|
631
|
+
catch (addError) {
|
|
632
|
+
setError(addError instanceof Error ? addError.message : 'Could not add this view');
|
|
633
|
+
// Host dialogs may finish on the same click that dismisses their layer.
|
|
634
|
+
// Reopen after that event so cancellation always leaves this draft retryable.
|
|
635
|
+
window.setTimeout(() => setOpen(true), 0);
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
setSaving(false);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
return (_jsxs("div", { className: "relative shrink-0", children: [_jsx(ConditionalTooltip, { enabled: unavailableReason !== undefined, label: unavailableReason ?? '', side: "bottom", align: align, delayDuration: 200, children: _jsx("button", { ref: triggerRef, type: "button", "data-id": "jsonView-add-view", "aria-label": unavailableReason ? `New view unavailable: ${unavailableReason}` : 'New view', "aria-disabled": disabled || unavailableReason !== undefined, title: unavailableReason ? undefined : 'Save this location as a view', disabled: disabled, className: cn('grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', unavailableReason && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground'), onClick: () => {
|
|
642
|
+
if (unavailableReason)
|
|
643
|
+
return;
|
|
644
|
+
if (open)
|
|
645
|
+
close();
|
|
646
|
+
else {
|
|
647
|
+
reset();
|
|
648
|
+
setOpen(true);
|
|
649
|
+
}
|
|
650
|
+
}, children: _jsx(Plus, { className: "h-3.5 w-3.5" }) }) }), open && typeof document !== 'undefined' && createPortal(_jsxs("section", { ref: popoverRef, "data-id": "jsonView-add-view-menu", "aria-label": "Create new view", style: position, className: cn('z-[100] w-[min(20rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-3 text-xs text-foreground shadow-xl', align === 'end' ? '[transform-origin:top_right]' : '[transform-origin:top_left]'), children: [_jsxs("header", { className: "mb-2 px-0.5", children: [_jsx("h3", { className: "text-sm font-medium", children: "New view" }), _jsx("p", { className: "mt-0.5 text-[0.6875rem] text-muted-foreground", children: "Save the current location as a view." })] }), selectedOption ? (_jsxs("div", { className: "grid gap-3", children: [_jsxs("label", { children: [_jsx("span", { className: "sr-only", children: "Name" }), _jsx("input", { autoFocus: true, "aria-label": "New view name", className: `${INPUT_CLASS} w-full`, placeholder: "View name", value: name, onChange: (event) => setName(event.target.value) })] }), selectedOption?.recordCollection ? _jsxs("fieldset", { children: [_jsx("legend", { className: "sr-only", children: "View type" }), _jsx("div", { className: "grid grid-cols-2 gap-1 rounded-lg bg-muted p-1", children: [
|
|
651
|
+
{ value: 'table', label: 'Table', Icon: Table2 },
|
|
652
|
+
{ value: 'kanban', label: 'Kanban', Icon: Columns3 },
|
|
653
|
+
].map(({ value, label, Icon }) => (_jsxs("button", { type: "button", "data-id": `jsonView-new-${value}`, "aria-pressed": display === value, className: cn('flex h-8 w-full items-center justify-center gap-1.5 rounded-md px-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', display === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'), onClick: () => {
|
|
654
|
+
setDisplay(value);
|
|
655
|
+
if (value === 'kanban' && !groupBy)
|
|
656
|
+
setGroupBy(preferredGroup?.path ?? '');
|
|
657
|
+
}, children: [_jsx(Icon, { className: "h-3.5 w-3.5" }), label] }, value))) })] }) : _jsx("p", { className: "px-2 py-1.5 text-muted-foreground", children: "Uses the natural JSON layout." }), selectedOption?.recordCollection && display === 'kanban' && (_jsxs("label", { className: "grid gap-1 text-muted-foreground", children: ["Group by", _jsx(MenuSelect, { ariaLabel: "New view group by", emptyOptionLabel: "Select a property", options: groupFields.map((field) => ({ value: field.path, label: field.defaultLabel })), value: groupBy, onChange: setGroupBy })] })), error && _jsx("p", { role: "alert", className: "text-destructive", children: error }), _jsxs("footer", { className: "flex justify-end gap-1.5 pt-0.5", children: [_jsx("button", { type: "button", className: "h-8 rounded-md px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground", onClick: close, children: "Cancel" }), _jsx("button", { type: "button", "data-id": "jsonView-create-view", disabled: disabled || saving || (selectedOption?.recordCollection === true && display === 'kanban' && !groupBy), className: "h-8 whitespace-nowrap rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50", onClick: () => { void add(); }, children: saving ? 'Creating…' : 'Create view' })] })] })) : (_jsx("p", { className: "px-2 pb-2 text-muted-foreground", children: "This location cannot be saved as a view." }))] }), portalContainer ?? document.body)] }));
|
|
658
|
+
}
|
|
659
|
+
export function JsonViewViewSettings(props) {
|
|
660
|
+
return props.view.display === 'html' ? _jsx(HtmlViewSettings, { ...props }) : _jsx(BuiltInViewSettings, { ...props });
|
|
661
|
+
}
|