@_linked/shape-ui 2.0.0 → 2.3.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/lib/esm/columns.d.ts +58 -0
- package/lib/esm/columns.js +173 -0
- package/lib/esm/columns.js.map +1 -0
- package/lib/esm/components/AddInstanceForms.d.ts +38 -0
- package/lib/esm/components/AddInstanceForms.js +143 -0
- package/lib/esm/components/AddInstanceForms.js.map +1 -0
- package/lib/esm/components/AddInstanceForms.module.css +12 -0
- package/lib/esm/components/BatchEditDrawer.d.ts +13 -0
- package/lib/esm/components/BatchEditDrawer.js +122 -0
- package/lib/esm/components/BatchEditDrawer.js.map +1 -0
- package/lib/esm/components/BatchEditDrawer.module.css +193 -0
- package/lib/esm/components/CustomMultiSelect.d.ts +26 -0
- package/lib/esm/components/CustomMultiSelect.js +344 -0
- package/lib/esm/components/CustomMultiSelect.js.map +1 -0
- package/lib/esm/components/CustomMultiSelect.module.css +291 -0
- package/lib/esm/components/DynamicFormField.d.ts +21 -0
- package/lib/esm/components/DynamicFormField.js +149 -0
- package/lib/esm/components/DynamicFormField.js.map +1 -0
- package/lib/esm/components/DynamicFormField.module.css +169 -0
- package/lib/esm/components/EditInstanceForms.d.ts +15 -0
- package/lib/esm/components/EditInstanceForms.js +118 -0
- package/lib/esm/components/EditInstanceForms.js.map +1 -0
- package/lib/esm/components/FilterBadge.d.ts +20 -0
- package/lib/esm/components/FilterBadge.js +26 -0
- package/lib/esm/components/FilterBadge.js.map +1 -0
- package/lib/esm/components/FilterBadge.module.css +25 -0
- package/lib/esm/components/FormField.d.ts +18 -0
- package/lib/esm/components/FormField.js +9 -0
- package/lib/esm/components/FormField.js.map +1 -0
- package/lib/esm/components/FormField.module.css +57 -0
- package/lib/esm/components/InstanceDeletionDialog.d.ts +17 -0
- package/lib/esm/components/InstanceDeletionDialog.js +83 -0
- package/lib/esm/components/InstanceDeletionDialog.js.map +1 -0
- package/lib/esm/components/InstanceDeletionDialog.module.css +59 -0
- package/lib/esm/components/InstanceOverview.d.ts +43 -0
- package/lib/esm/components/InstanceOverview.js +236 -0
- package/lib/esm/components/InstanceOverview.js.map +1 -0
- package/lib/esm/components/InstanceOverview.module.css +35 -0
- package/lib/esm/components/InstanceView.d.ts +29 -0
- package/lib/esm/components/InstanceView.js +97 -0
- package/lib/esm/components/InstanceView.js.map +1 -0
- package/lib/esm/components/InstanceView.module.css +157 -0
- package/lib/esm/components/NodeBadge.d.ts +25 -0
- package/lib/esm/components/NodeBadge.js +29 -0
- package/lib/esm/components/NodeBadge.js.map +1 -0
- package/lib/esm/components/NodeValuesEditor.d.ts +15 -0
- package/lib/esm/components/NodeValuesEditor.js +29 -0
- package/lib/esm/components/NodeValuesEditor.js.map +1 -0
- package/lib/esm/components/NodeValuesEditor.module.css +4 -0
- package/lib/esm/components/ReactTable.d.ts +59 -0
- package/lib/esm/components/ReactTable.js +378 -0
- package/lib/esm/components/ReactTable.js.map +1 -0
- package/lib/esm/components/ReactTable.module.css +950 -0
- package/lib/esm/components/TextfieldEditor.d.ts +1 -2
- package/lib/esm/components/TextfieldEditor.js +2 -2
- package/lib/esm/components/TextfieldEditor.js.map +1 -1
- package/lib/esm/components/forms.module.css +96 -0
- package/lib/esm/host.d.ts +196 -0
- package/lib/esm/host.js +25 -0
- package/lib/esm/host.js.map +1 -0
- package/lib/esm/hostContext.d.ts +34 -0
- package/lib/esm/hostContext.js +95 -0
- package/lib/esm/hostContext.js.map +1 -0
- package/lib/esm/index.d.ts +60 -10
- package/lib/esm/index.js +60 -14
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/read.d.ts +48 -0
- package/lib/esm/read.js +96 -0
- package/lib/esm/read.js.map +1 -0
- package/lib/esm/search.d.ts +32 -0
- package/lib/esm/search.js +87 -0
- package/lib/esm/search.js.map +1 -0
- package/lib/esm/shape/contracts.d.ts +58 -0
- package/lib/esm/shape/contracts.js +15 -0
- package/lib/esm/shape/contracts.js.map +1 -0
- package/lib/esm/shape/naming.d.ts +30 -0
- package/lib/esm/shape/naming.js +52 -0
- package/lib/esm/shape/naming.js.map +1 -0
- package/lib/esm/shape/nodeDisplay.d.ts +36 -0
- package/lib/esm/shape/nodeDisplay.js +117 -0
- package/lib/esm/shape/nodeDisplay.js.map +1 -0
- package/lib/esm/shape/propertyTypes.d.ts +12 -0
- package/lib/esm/shape/propertyTypes.js +60 -0
- package/lib/esm/shape/propertyTypes.js.map +1 -0
- package/lib/esm/shape/propertyVisibility.d.ts +56 -0
- package/lib/esm/shape/propertyVisibility.js +112 -0
- package/lib/esm/shape/propertyVisibility.js.map +1 -0
- package/lib/esm/shape/shapeDependencies.d.ts +41 -0
- package/lib/esm/shape/shapeDependencies.js +141 -0
- package/lib/esm/shape/shapeDependencies.js.map +1 -0
- package/lib/esm/shape/tableFilters.d.ts +35 -0
- package/lib/esm/shape/tableFilters.js +171 -0
- package/lib/esm/shape/tableFilters.js.map +1 -0
- package/lib/esm/shape/useFormDependencies.d.ts +38 -0
- package/lib/esm/shape/useFormDependencies.js +178 -0
- package/lib/esm/shape/useFormDependencies.js.map +1 -0
- package/lib/esm/shape/validation.d.ts +15 -0
- package/lib/esm/shape/validation.js +106 -0
- package/lib/esm/shape/validation.js.map +1 -0
- package/lib/esm/tokens.css +25 -0
- package/lib/esm/types.d.ts +22 -0
- package/lib/esm/types.js +1 -0
- package/lib/esm/types.js.map +1 -0
- package/lib/esm/write.d.ts +51 -0
- package/lib/esm/write.js +99 -0
- package/lib/esm/write.js.map +1 -0
- package/package.json +44 -18
- package/src/backend.ts +1 -0
- package/src/columns.regression.test.ts +143 -0
- package/src/columns.test.ts +200 -0
- package/src/columns.ts +228 -0
- package/src/components/AddInstanceForms.module.css +12 -0
- package/src/components/AddInstanceForms.tsx +321 -0
- package/src/components/AvatarEditor.tsx +34 -0
- package/src/components/BatchEditDrawer.module.css +193 -0
- package/src/components/BatchEditDrawer.tsx +295 -0
- package/src/components/CheckboxEditor.module.css +6 -0
- package/src/components/CheckboxEditor.tsx +86 -0
- package/src/components/CustomMultiSelect.module.css +291 -0
- package/src/components/CustomMultiSelect.tsx +527 -0
- package/src/components/DateEditor.tsx +50 -0
- package/src/components/DynamicFormField.module.css +169 -0
- package/src/components/DynamicFormField.tsx +357 -0
- package/src/components/EditInstanceForms.tsx +260 -0
- package/src/components/FilterBadge.module.css +25 -0
- package/src/components/FilterBadge.tsx +70 -0
- package/src/components/FormField.module.css +57 -0
- package/src/components/FormField.tsx +59 -0
- package/src/components/ImageValueEditor.module.css +3 -0
- package/src/components/ImageValueEditor.tsx +82 -0
- package/src/components/InstanceDeletionDialog.module.css +59 -0
- package/src/components/InstanceDeletionDialog.tsx +175 -0
- package/src/components/InstanceOverview.module.css +35 -0
- package/src/components/InstanceOverview.tsx +385 -0
- package/src/components/InstanceView.module.css +157 -0
- package/src/components/InstanceView.tsx +212 -0
- package/src/components/NodeBadge.tsx +60 -0
- package/src/components/NodeValuesEditor.module.css +4 -0
- package/src/components/NodeValuesEditor.tsx +64 -0
- package/src/components/RadioButtonEditor.module.css +22 -0
- package/src/components/RadioButtonEditor.tsx +96 -0
- package/src/components/ReactTable.module.css +950 -0
- package/src/components/ReactTable.tsx +1013 -0
- package/src/components/SelectEditor.tsx +85 -0
- package/src/components/ShapeTable.module.css +394 -0
- package/src/components/SwitchEditor.tsx +60 -0
- package/src/components/TextareaEditor.tsx +55 -0
- package/src/components/TextfieldEditor.tsx +67 -0
- package/src/components/ToggleEditor.tsx +63 -0
- package/src/components/forms.module.css +96 -0
- package/src/host.ts +226 -0
- package/src/hostContext.tsx +114 -0
- package/src/index.ts +93 -0
- package/src/package.ts +21 -0
- package/src/read.ts +148 -0
- package/src/search.test.ts +174 -0
- package/src/search.ts +108 -0
- package/src/shape/contracts.ts +66 -0
- package/src/shape/naming.ts +52 -0
- package/src/shape/nodeDisplay.ts +114 -0
- package/src/shape/propertyTypes.ts +86 -0
- package/src/shape/propertyVisibility.ts +180 -0
- package/src/shape/shapeDependencies.ts +183 -0
- package/src/shape/tableFilters.ts +223 -0
- package/src/shape/types.ts +1158 -0
- package/src/shape/useFormDependencies.ts +257 -0
- package/src/shape/validation.ts +121 -0
- package/src/tokens.css +25 -0
- package/src/types.ts +20 -0
- package/src/utils/helper.ts +35 -0
- package/src/write.test.ts +174 -0
- package/src/write.ts +114 -0
- package/.changeset/README.md +0 -20
- package/.changeset/config.json +0 -14
- package/.github/workflows/changeset-check.yml +0 -44
- package/.github/workflows/ci.yml +0 -43
- package/.github/workflows/publish.yml +0 -158
- package/CHANGELOG.md +0 -30
- package/lib/esm/components/AvatarEditor.d.ts +0 -20
- package/lib/esm/components/AvatarEditor.js +0 -7
- package/lib/esm/components/AvatarEditor.js.map +0 -1
- package/lib/esm/components/CheckboxEditor.d.ts +0 -21
- package/lib/esm/components/CheckboxEditor.js +0 -44
- package/lib/esm/components/CheckboxEditor.js.map +0 -1
- package/lib/esm/components/DateEditor.d.ts +0 -20
- package/lib/esm/components/DateEditor.js +0 -22
- package/lib/esm/components/DateEditor.js.map +0 -1
- package/lib/esm/components/RadioButtonEditor.d.ts +0 -23
- package/lib/esm/components/RadioButtonEditor.js +0 -41
- package/lib/esm/components/RadioButtonEditor.js.map +0 -1
- package/lib/esm/components/SelectEditor.d.ts +0 -21
- package/lib/esm/components/SelectEditor.js +0 -30
- package/lib/esm/components/SelectEditor.js.map +0 -1
- package/lib/esm/components/SwitchEditor.d.ts +0 -21
- package/lib/esm/components/SwitchEditor.js +0 -23
- package/lib/esm/components/SwitchEditor.js.map +0 -1
- package/lib/esm/components/TextareaEditor.d.ts +0 -23
- package/lib/esm/components/TextareaEditor.js +0 -15
- package/lib/esm/components/TextareaEditor.js.map +0 -1
- package/lib/esm/components/ToggleEditor.d.ts +0 -22
- package/lib/esm/components/ToggleEditor.js +0 -23
- package/lib/esm/components/ToggleEditor.js.map +0 -1
- package/lib/esm/ontologies/lincd-ui.d.ts +0 -6
- package/lib/esm/ontologies/lincd-ui.js +0 -18
- package/lib/esm/ontologies/lincd-ui.js.map +0 -1
- package/tsconfig-cjs.json +0 -8
- package/tsconfig-esm.json +0 -9
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { Button } from '@_linked/primitives/components/Button';
|
|
2
|
+
import { Command } from '@_linked/primitives/components/Command';
|
|
3
|
+
import { Dialog } from '@_linked/primitives/components/Dialog';
|
|
4
|
+
import { Popover } from '@_linked/primitives/components/Popover';
|
|
5
|
+
import type { PropertyShapeWire, NodeShapeWire } from '@_linked/core/shapes/nodeShapeWire';
|
|
6
|
+
import { Shape } from '@_linked/core/shapes/Shape';
|
|
7
|
+
import { cl } from '@_linked/react/utils/ClassNames';
|
|
8
|
+
import { getNodeDisplay } from '../shape/nodeDisplay.js';
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { useDataManagerHost } from '../hostContext.js';
|
|
11
|
+
import { formatShapeLabel } from '../shape/naming.js';
|
|
12
|
+
import { Icons } from '@_linked/icons';
|
|
13
|
+
import { InlineImageThumb } from '@_linked/primitives/components/ImageThumb';
|
|
14
|
+
import { Spinner } from '@_linked/primitives/components/Spinner';
|
|
15
|
+
import style from './CustomMultiSelect.module.css';
|
|
16
|
+
|
|
17
|
+
interface CustomMultiSelectProps {
|
|
18
|
+
values?;
|
|
19
|
+
property?: PropertyShapeWire;
|
|
20
|
+
/**
|
|
21
|
+
* The shape whose instance is being edited — the OWNER of `property`, not the shape being
|
|
22
|
+
* picked from. Needed for the pick-mode round-trip, which has to know where to return to.
|
|
23
|
+
*
|
|
24
|
+
* Supply these instead of letting the component read the route: it used to derive both
|
|
25
|
+
* from `useParams().shape_uri` plus CN's settings context, which assumed CN's URL layout
|
|
26
|
+
* for a value every caller already holds.
|
|
27
|
+
*/
|
|
28
|
+
sourceShape?: NodeShapeWire;
|
|
29
|
+
sourceShapeUri?: string;
|
|
30
|
+
onShowAsTable?: () => void;
|
|
31
|
+
onChange?: (values: { id: string; label: string; image?: string }[]) => void;
|
|
32
|
+
onBeforeNavigate?: () => Promise<string | void> | string | void;
|
|
33
|
+
narrowedIds?: string[] | null;
|
|
34
|
+
isNarrowing?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CustomMultiSelect = ({
|
|
38
|
+
values,
|
|
39
|
+
property,
|
|
40
|
+
sourceShape,
|
|
41
|
+
sourceShapeUri,
|
|
42
|
+
onShowAsTable,
|
|
43
|
+
onChange,
|
|
44
|
+
onBeforeNavigate,
|
|
45
|
+
narrowedIds,
|
|
46
|
+
isNarrowing,
|
|
47
|
+
}: CustomMultiSelectProps) => {
|
|
48
|
+
const [comboboxValues, setComboboxValues] = useState<
|
|
49
|
+
{ id: string; label: string; image?: string }[]
|
|
50
|
+
>([]);
|
|
51
|
+
const [isComboboxExpanded, setIsComboboxExpanded] = useState(false);
|
|
52
|
+
const [isComboboxOpen, setIsComboboxOpen] = useState(false);
|
|
53
|
+
const [comboboxInputValue, setComboboxInputValue] = useState('');
|
|
54
|
+
const [createNewInstanceFor, setCreateNewInstanceFor] = useState(null);
|
|
55
|
+
const [maxVisibleOptions, setMaxVisibleOptions] = useState(5);
|
|
56
|
+
const [searchResults, setSearchResults] = useState<{ id: string; label: string; image?: string }[]>([]);
|
|
57
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
58
|
+
const [initialOptions, setInitialOptions] = useState<{ id: string; label: string; image?: string }[]>([]);
|
|
59
|
+
const [isLoadingInitial, setIsLoadingInitial] = useState(false);
|
|
60
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
61
|
+
const [hasMore, setHasMore] = useState(false);
|
|
62
|
+
const [browseOffset, setBrowseOffset] = useState(0);
|
|
63
|
+
const [searchOffset, setSearchOffset] = useState(0);
|
|
64
|
+
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
|
|
65
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
67
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
68
|
+
// The shape being edited arrives as a prop. It used to be read from the route
|
|
69
|
+
// (`useParams().shape_uri` + `getShapeFromParams(..., projectConfig)`), which assumed
|
|
70
|
+
// CN's URL layout AND CN's settings context — two couplings for one value every caller
|
|
71
|
+
// already has in hand.
|
|
72
|
+
const shape_uri = sourceShapeUri ?? sourceShape?.id;
|
|
73
|
+
// The host supplies searching, inline creation and navigation. The picker used to reach
|
|
74
|
+
// for `Project.searchInstances` + `useSettings` + the router directly, and to import
|
|
75
|
+
// `AddInstanceForms` — a molecule importing an organism, which is both a cycle and the
|
|
76
|
+
// one thing that made this component unextractable.
|
|
77
|
+
const host = useDataManagerHost();
|
|
78
|
+
const nodeShape = sourceShape as NodeShapeWire;
|
|
79
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
80
|
+
const valueArray = (Array.isArray(values) ? values : [values]).filter(Boolean);
|
|
81
|
+
|
|
82
|
+
const isInitialMount = useRef(true);
|
|
83
|
+
|
|
84
|
+
const renderText = (shape: Shape) => {
|
|
85
|
+
return getNodeDisplay(shape);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Extract an image URL from a shape value (for initial values from props) */
|
|
89
|
+
const getImageFromValue = (val: any): string | undefined => {
|
|
90
|
+
if (!val) return undefined;
|
|
91
|
+
// Direct contentUrl (ImageObject)
|
|
92
|
+
if (val.contentUrl) return String(val.contentUrl);
|
|
93
|
+
// schema:image → contentUrl
|
|
94
|
+
const img = val.image;
|
|
95
|
+
if (img) {
|
|
96
|
+
if (typeof img === 'string') return img;
|
|
97
|
+
if (Array.isArray(img)) {
|
|
98
|
+
const first = img[0];
|
|
99
|
+
return typeof first === 'string' ? first : first?.contentUrl ? String(first.contentUrl) : undefined;
|
|
100
|
+
}
|
|
101
|
+
if (img.contentUrl) return String(img.contentUrl);
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Init selected values from props
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const alreadySelectedValues =
|
|
109
|
+
valueArray.length > 0
|
|
110
|
+
? valueArray.map((el) => ({
|
|
111
|
+
id: el.id,
|
|
112
|
+
label: renderText(el),
|
|
113
|
+
image: getImageFromValue(el),
|
|
114
|
+
}))
|
|
115
|
+
: [];
|
|
116
|
+
setComboboxValues(alreadySelectedValues);
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
// The return leg of the pick round trip.
|
|
120
|
+
//
|
|
121
|
+
// This field sent the viewer away to choose and was unmounted while they did, so the
|
|
122
|
+
// answer cannot arrive through a callback — it has to be collected on the way back in.
|
|
123
|
+
// Where it was kept is the host's business; this used to read CN's sessionStorage
|
|
124
|
+
// directly, which is why the component only worked inside Create Now.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!property?.label) return;
|
|
127
|
+
const pickedItems = host.picking?.takeResult({
|
|
128
|
+
propertyLabel: property.label,
|
|
129
|
+
sourceShapeIri: shape_uri,
|
|
130
|
+
});
|
|
131
|
+
if (!pickedItems || pickedItems.length === 0) return;
|
|
132
|
+
setComboboxValues((prev) => {
|
|
133
|
+
let next = [...prev];
|
|
134
|
+
for (const picked of pickedItems) {
|
|
135
|
+
if (next.some((v) => v.id === picked.id)) continue;
|
|
136
|
+
if (property?.maxCount === 1) return [picked];
|
|
137
|
+
if (property?.maxCount && next.length >= property.maxCount) break;
|
|
138
|
+
next.push(picked);
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
// Propagate comboboxValues changes to parent via onChange
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (isInitialMount.current) {
|
|
147
|
+
isInitialMount.current = false;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
onChange?.(comboboxValues);
|
|
151
|
+
}, [comboboxValues]);
|
|
152
|
+
|
|
153
|
+
// Debounced backend search — resets offset on new search text
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
156
|
+
const valueShapeId = property?.valueShape?.id;
|
|
157
|
+
if (!comboboxInputValue || !host.searchInstances || !valueShapeId) {
|
|
158
|
+
setSearchResults([]);
|
|
159
|
+
setSearchOffset(0);
|
|
160
|
+
setHasMore(false);
|
|
161
|
+
setIsSearching(false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setIsSearching(true);
|
|
165
|
+
setSearchOffset(0);
|
|
166
|
+
debounceRef.current = setTimeout(async () => {
|
|
167
|
+
try {
|
|
168
|
+
const response = await host.searchInstances(valueShapeId, {
|
|
169
|
+
query: comboboxInputValue,
|
|
170
|
+
limit: 20,
|
|
171
|
+
offset: 0,
|
|
172
|
+
narrowedIds: narrowedIds || undefined,
|
|
173
|
+
});
|
|
174
|
+
setSearchResults(response?.results || []);
|
|
175
|
+
setHasMore(response?.hasMore ?? false);
|
|
176
|
+
} catch {
|
|
177
|
+
setSearchResults([]);
|
|
178
|
+
setHasMore(false);
|
|
179
|
+
} finally {
|
|
180
|
+
setIsSearching(false);
|
|
181
|
+
}
|
|
182
|
+
}, 300);
|
|
183
|
+
return () => {
|
|
184
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
185
|
+
};
|
|
186
|
+
}, [comboboxInputValue, host, property?.valueShape?.id, narrowedIds]);
|
|
187
|
+
|
|
188
|
+
// Load initial options when popover opens or narrowing changes (browse mode)
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!isComboboxOpen) return;
|
|
191
|
+
// Capture trigger width for popover alignment
|
|
192
|
+
if (triggerRef.current) {
|
|
193
|
+
setTriggerWidth(triggerRef.current.getBoundingClientRect().width);
|
|
194
|
+
}
|
|
195
|
+
const valueShapeId = property?.valueShape?.id;
|
|
196
|
+
if (!host.searchInstances || !valueShapeId) return;
|
|
197
|
+
setIsLoadingInitial(true);
|
|
198
|
+
setBrowseOffset(0);
|
|
199
|
+
host
|
|
200
|
+
.searchInstances(valueShapeId, {
|
|
201
|
+
query: '',
|
|
202
|
+
limit: 20,
|
|
203
|
+
offset: 0,
|
|
204
|
+
narrowedIds: narrowedIds || undefined,
|
|
205
|
+
})
|
|
206
|
+
.then((response) => {
|
|
207
|
+
setInitialOptions(response?.results || []);
|
|
208
|
+
setHasMore(response?.hasMore ?? false);
|
|
209
|
+
})
|
|
210
|
+
.catch(() => {
|
|
211
|
+
setInitialOptions([]);
|
|
212
|
+
setHasMore(false);
|
|
213
|
+
})
|
|
214
|
+
.finally(() => setIsLoadingInitial(false));
|
|
215
|
+
}, [isComboboxOpen, narrowedIds]);
|
|
216
|
+
|
|
217
|
+
// Load more results (infinite scroll) for both browse and search modes
|
|
218
|
+
const loadMore = useCallback(async () => {
|
|
219
|
+
const valueShapeId = property?.valueShape?.id;
|
|
220
|
+
if (!host.searchInstances || !valueShapeId || isLoadingMore || !hasMore) return;
|
|
221
|
+
|
|
222
|
+
setIsLoadingMore(true);
|
|
223
|
+
const isSearchMode = !!comboboxInputValue;
|
|
224
|
+
const currentOffset = isSearchMode ? searchOffset : browseOffset;
|
|
225
|
+
const nextOffset = currentOffset + 20;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const response = await host.searchInstances(valueShapeId, {
|
|
229
|
+
query: isSearchMode ? comboboxInputValue : '',
|
|
230
|
+
limit: 20,
|
|
231
|
+
offset: nextOffset,
|
|
232
|
+
narrowedIds: narrowedIds || undefined,
|
|
233
|
+
});
|
|
234
|
+
const newResults = response?.results || [];
|
|
235
|
+
setHasMore(response?.hasMore ?? false);
|
|
236
|
+
|
|
237
|
+
if (isSearchMode) {
|
|
238
|
+
setSearchResults((prev) => [...prev, ...newResults]);
|
|
239
|
+
setSearchOffset(nextOffset);
|
|
240
|
+
} else {
|
|
241
|
+
setInitialOptions((prev) => [...prev, ...newResults]);
|
|
242
|
+
setBrowseOffset(nextOffset);
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
setHasMore(false);
|
|
246
|
+
} finally {
|
|
247
|
+
setIsLoadingMore(false);
|
|
248
|
+
}
|
|
249
|
+
}, [host, property?.valueShape?.id, comboboxInputValue, isLoadingMore, hasMore, searchOffset, browseOffset]);
|
|
250
|
+
|
|
251
|
+
// IntersectionObserver for infinite scroll sentinel
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const sentinel = sentinelRef.current;
|
|
254
|
+
if (!sentinel) return;
|
|
255
|
+
|
|
256
|
+
const observer = new IntersectionObserver(
|
|
257
|
+
(entries) => {
|
|
258
|
+
if (entries[0]?.isIntersecting && hasMore && !isLoadingMore) {
|
|
259
|
+
loadMore();
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{ threshold: 0.1 }
|
|
263
|
+
);
|
|
264
|
+
observer.observe(sentinel);
|
|
265
|
+
return () => observer.disconnect();
|
|
266
|
+
}, [hasMore, isLoadingMore, loadMore]);
|
|
267
|
+
|
|
268
|
+
// Responsive max visible chips
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
const update = () => {
|
|
271
|
+
if (window.innerWidth < 768) setMaxVisibleOptions(3);
|
|
272
|
+
else if (window.innerWidth < 1024) setMaxVisibleOptions(4);
|
|
273
|
+
else setMaxVisibleOptions(5);
|
|
274
|
+
};
|
|
275
|
+
update();
|
|
276
|
+
window.addEventListener('resize', update);
|
|
277
|
+
return () => window.removeEventListener('resize', update);
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
const multiselect = property.maxCount > 1;
|
|
281
|
+
const onUpdateCombobox = (item: { id: string; label: string; image?: string }) => {
|
|
282
|
+
if (multiselect) {
|
|
283
|
+
if (comboboxValues?.some((v) => v.id === item.id)) {
|
|
284
|
+
setComboboxValues((prev) => prev.filter((e) => e.id !== item.id));
|
|
285
|
+
} else {
|
|
286
|
+
// Enforce maxCount limit
|
|
287
|
+
if (property?.maxCount && comboboxValues.length >= property.maxCount) return;
|
|
288
|
+
setComboboxValues((prev) => [...prev, item]);
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
// Single select: toggle — if already selected, deselect; otherwise select
|
|
292
|
+
if (comboboxValues?.some((v) => v.id === item.id)) {
|
|
293
|
+
setComboboxValues([]);
|
|
294
|
+
} else {
|
|
295
|
+
setComboboxValues([item]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** Dedicated removal handler for the X button on chips — always removes regardless of multiselect */
|
|
301
|
+
const onRemoveValue = (e: React.MouseEvent, item: { id: string; label: string; image?: string }) => {
|
|
302
|
+
e.stopPropagation();
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
setComboboxValues((prev) => prev.filter((v) => v.id !== item.id));
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const onClickTable = async () => {
|
|
308
|
+
if (onShowAsTable) {
|
|
309
|
+
onShowAsTable();
|
|
310
|
+
} else if (property?.valueShape?.id) {
|
|
311
|
+
// Flush draft to server before navigating away — returns the draftId
|
|
312
|
+
const flushedDraftId = await onBeforeNavigate?.();
|
|
313
|
+
// How "browse all of these" is presented is the host's decision — Create Now
|
|
314
|
+
// navigates to the overview in pick mode; another host might open a sheet. The
|
|
315
|
+
// flushed draft id rides along as the resume token so the half-filled form survives
|
|
316
|
+
// whatever round trip the host chooses.
|
|
317
|
+
host.picking?.open({
|
|
318
|
+
shapeIri: property.valueShape.id,
|
|
319
|
+
propertyLabel: property.label,
|
|
320
|
+
sourceShapeIri: shape_uri,
|
|
321
|
+
maxCount: property.maxCount,
|
|
322
|
+
minCount: property.minCount,
|
|
323
|
+
alreadySelected: comboboxValues,
|
|
324
|
+
resumeToken: flushedDraftId || undefined,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const dropdownOptions: { id: string; label: string; image?: string }[] = comboboxInputValue
|
|
330
|
+
? searchResults
|
|
331
|
+
: initialOptions.length > 0
|
|
332
|
+
? initialOptions
|
|
333
|
+
: valueArray.map((el) => ({ id: el.id, label: renderText(el), image: getImageFromValue(el) }));
|
|
334
|
+
|
|
335
|
+
const openCreateNew = () => {
|
|
336
|
+
setCreateNewInstanceFor(nodeShape);
|
|
337
|
+
setIsComboboxOpen(false); // close popover first — avoids z-index overlap
|
|
338
|
+
setIsDialogOpen(true);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<div className={style.Root}>
|
|
343
|
+
<Popover.Root
|
|
344
|
+
open={isComboboxOpen}
|
|
345
|
+
onOpenChange={setIsComboboxOpen}
|
|
346
|
+
>
|
|
347
|
+
<Popover.Trigger asChild>
|
|
348
|
+
<div ref={triggerRef} className={style.selectedOptionsAndChevronIconContainer}>
|
|
349
|
+
<div
|
|
350
|
+
className={cl(
|
|
351
|
+
style.selectedOptionsContainer,
|
|
352
|
+
isComboboxExpanded ? style.expanded : ''
|
|
353
|
+
)}
|
|
354
|
+
>
|
|
355
|
+
<div
|
|
356
|
+
className={cl(
|
|
357
|
+
style.noBorderContainer,
|
|
358
|
+
isComboboxExpanded ? style.expanded : ''
|
|
359
|
+
)}
|
|
360
|
+
>
|
|
361
|
+
{comboboxValues.length > 0 ? (
|
|
362
|
+
comboboxValues.slice(0, maxVisibleOptions).map((val, i) => (
|
|
363
|
+
<Button key={i} size="small" className={style.optionButton}>
|
|
364
|
+
{val.image && <InlineImageThumb src={val.image} />}
|
|
365
|
+
{val.label}
|
|
366
|
+
<Icons.Cross2
|
|
367
|
+
onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
|
368
|
+
onClick={(e) => onRemoveValue(e, val)}
|
|
369
|
+
/>
|
|
370
|
+
</Button>
|
|
371
|
+
))
|
|
372
|
+
) : (
|
|
373
|
+
<p className={style.selectorButton}>Select value</p>
|
|
374
|
+
)}
|
|
375
|
+
{comboboxValues.length > maxVisibleOptions &&
|
|
376
|
+
(isComboboxExpanded ? (
|
|
377
|
+
comboboxValues
|
|
378
|
+
.slice(maxVisibleOptions)
|
|
379
|
+
.map((val, i) => (
|
|
380
|
+
<Button key={i} size="small" className={style.optionButton}>
|
|
381
|
+
{val.image && <InlineImageThumb src={val.image} />}
|
|
382
|
+
{val.label}
|
|
383
|
+
<Icons.Cross2
|
|
384
|
+
onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
|
385
|
+
onClick={(e) => onRemoveValue(e, val)}
|
|
386
|
+
/>
|
|
387
|
+
</Button>
|
|
388
|
+
))
|
|
389
|
+
) : (
|
|
390
|
+
<Button
|
|
391
|
+
size="small"
|
|
392
|
+
className={style.optionButton}
|
|
393
|
+
onClick={() => setIsComboboxExpanded(true)}
|
|
394
|
+
>
|
|
395
|
+
... {comboboxValues.slice(maxVisibleOptions).length} more
|
|
396
|
+
</Button>
|
|
397
|
+
))}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
<Icons.ChevronDown
|
|
401
|
+
className={cl(
|
|
402
|
+
style.chevronDownIcon,
|
|
403
|
+
isComboboxOpen ? style.open : ''
|
|
404
|
+
)}
|
|
405
|
+
/>
|
|
406
|
+
{isComboboxExpanded && (
|
|
407
|
+
<p
|
|
408
|
+
className={style.viewLessText}
|
|
409
|
+
onClick={() => setIsComboboxExpanded(false)}
|
|
410
|
+
>
|
|
411
|
+
View Less
|
|
412
|
+
</p>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
</Popover.Trigger>
|
|
416
|
+
|
|
417
|
+
<Popover.Content
|
|
418
|
+
className={style.options}
|
|
419
|
+
style={triggerWidth ? { width: triggerWidth } : undefined}
|
|
420
|
+
align="start"
|
|
421
|
+
>
|
|
422
|
+
<Command.Root shouldFilter={false}>
|
|
423
|
+
{/* Search input row */}
|
|
424
|
+
<div className={style.searchWrapper}>
|
|
425
|
+
<Command.Input
|
|
426
|
+
value={comboboxInputValue}
|
|
427
|
+
onValueChange={setComboboxInputValue}
|
|
428
|
+
placeholder="Search..."
|
|
429
|
+
/>
|
|
430
|
+
{comboboxInputValue && (
|
|
431
|
+
<Icons.Cross2
|
|
432
|
+
className={style.resetSearchInputIcon}
|
|
433
|
+
onClick={() => setComboboxInputValue('')}
|
|
434
|
+
/>
|
|
435
|
+
)}
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
{/* Options list */}
|
|
439
|
+
<Command.List className={style.commandRoot}>
|
|
440
|
+
<Command.Empty className={style.emptyResult}>
|
|
441
|
+
{isLoadingInitial || isSearching
|
|
442
|
+
? <Spinner size="small" />
|
|
443
|
+
: comboboxInputValue
|
|
444
|
+
? `No results for "${comboboxInputValue}"`
|
|
445
|
+
: 'No options available'}
|
|
446
|
+
</Command.Empty>
|
|
447
|
+
<Command.Group className={style.commandGroup}>
|
|
448
|
+
{dropdownOptions.map((option) => {
|
|
449
|
+
const selected = comboboxValues.some((v) => v.id === option.id);
|
|
450
|
+
return (
|
|
451
|
+
<Command.Item
|
|
452
|
+
key={option.id}
|
|
453
|
+
value={option.id}
|
|
454
|
+
onSelect={(currentValue) => {
|
|
455
|
+
const item = dropdownOptions.find(o => o.id === currentValue);
|
|
456
|
+
if (item) onUpdateCombobox(item);
|
|
457
|
+
}}
|
|
458
|
+
className={cl(style.option, selected ? style.optionSelected : '')}
|
|
459
|
+
>
|
|
460
|
+
{option.image && <InlineImageThumb src={option.image} />}
|
|
461
|
+
<span className={style.optionLabel}>{option.label}</span>
|
|
462
|
+
{selected && <Icons.Check width="16" height="16" className={style.optionCheck} />}
|
|
463
|
+
</Command.Item>
|
|
464
|
+
);
|
|
465
|
+
})}
|
|
466
|
+
</Command.Group>
|
|
467
|
+
{/* Infinite scroll sentinel + loading indicator */}
|
|
468
|
+
{hasMore && (
|
|
469
|
+
<div ref={sentinelRef} className={style.loadMoreSentinel}>
|
|
470
|
+
<Spinner active={isLoadingMore} size="small" />
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
</Command.List>
|
|
474
|
+
</Command.Root>
|
|
475
|
+
|
|
476
|
+
{/* "Create New" sits at the bottom of the popover, outside Command.List */}
|
|
477
|
+
<div className={style.createNewContainer}>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
className={style.modalButton}
|
|
481
|
+
onClick={openCreateNew}
|
|
482
|
+
>
|
|
483
|
+
<Icons.Plus width="14" height="14" />
|
|
484
|
+
<span>Create New</span>
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
</Popover.Content>
|
|
488
|
+
</Popover.Root>
|
|
489
|
+
|
|
490
|
+
{/* Dialog is a sibling of Popover — prevents portal/z-index layering conflict */}
|
|
491
|
+
<Dialog.Root open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
492
|
+
<Dialog.Content className={style.dialogRoot}>
|
|
493
|
+
<Dialog.Header className={style.dialogHeader}>
|
|
494
|
+
<Dialog.Title>
|
|
495
|
+
Create New {nodeShape?.label ? formatShapeLabel(nodeShape.label) : ''}
|
|
496
|
+
</Dialog.Title>
|
|
497
|
+
<Dialog.Description>
|
|
498
|
+
Fill the form to create a new {nodeShape?.label ? formatShapeLabel(nodeShape.label) : ''}
|
|
499
|
+
</Dialog.Description>
|
|
500
|
+
</Dialog.Header>
|
|
501
|
+
<div className={style.dialogContent}>
|
|
502
|
+
{/*
|
|
503
|
+
The host decides what "create one of these" looks like. This component used
|
|
504
|
+
to import `AddInstanceForms` and call `Project.createShapeInstance` itself —
|
|
505
|
+
a molecule reaching up into an organism and into the control plane. Now it
|
|
506
|
+
only decides WHEN to ask.
|
|
507
|
+
*/}
|
|
508
|
+
{host.inlineCreate?.({
|
|
509
|
+
shapeIri: (createNewInstanceFor || nodeShape)?.id,
|
|
510
|
+
onCreated: (created) => {
|
|
511
|
+
setIsDialogOpen(false);
|
|
512
|
+
if (created?.id) onUpdateCombobox(created);
|
|
513
|
+
},
|
|
514
|
+
onCancel: () => setIsDialogOpen(false),
|
|
515
|
+
})}
|
|
516
|
+
</div>
|
|
517
|
+
</Dialog.Content>
|
|
518
|
+
</Dialog.Root>
|
|
519
|
+
|
|
520
|
+
<div onClick={onClickTable} className={style.tableIcon}>
|
|
521
|
+
<Icons.Table width="25" height="25" />
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export default CustomMultiSelect;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface DateEditorProps {
|
|
4
|
+
onChange?: (e) => void;
|
|
5
|
+
of?: Record<string, any>;
|
|
6
|
+
property?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Converts the value returned by the shape, to the date string for the date field
|
|
9
|
+
* @param shapePropertyValue
|
|
10
|
+
* @returns
|
|
11
|
+
*/
|
|
12
|
+
preConvertValueFn?: (shapePropertyValue: any) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Converts the value of the date field to a value that can be provided to the set method of the shape
|
|
15
|
+
* @param inputValue
|
|
16
|
+
* @returns
|
|
17
|
+
*/
|
|
18
|
+
postConvertValueFn?: (inputValue: string) => any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DateEditor = ({
|
|
22
|
+
onChange,
|
|
23
|
+
of,
|
|
24
|
+
property,
|
|
25
|
+
preConvertValueFn,
|
|
26
|
+
postConvertValueFn,
|
|
27
|
+
|
|
28
|
+
...restProps
|
|
29
|
+
}: DateEditorProps) => {
|
|
30
|
+
const onChangeHandler = (e) => {
|
|
31
|
+
if (onChange) {
|
|
32
|
+
onChange(e);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<></>
|
|
38
|
+
// <Input
|
|
39
|
+
// onChange={onChangeHandler}
|
|
40
|
+
// defaultValue={
|
|
41
|
+
// of
|
|
42
|
+
// ? preConvertValueFn
|
|
43
|
+
// ? preConvertValueFn(of[property])
|
|
44
|
+
// : of[property]
|
|
45
|
+
// : ''
|
|
46
|
+
// }
|
|
47
|
+
// {...restProps}
|
|
48
|
+
// />
|
|
49
|
+
);
|
|
50
|
+
};
|