@qoretechnologies/reqraft 0.10.27-pr.74.gc5f6ca3 → 0.10.27
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/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +8 -1
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
- package/dist/components/searchFilterBar/index.d.ts +2 -0
- package/dist/components/searchFilterBar/index.d.ts.map +1 -0
- package/dist/components/searchFilterBar/index.js +18 -0
- package/dist/components/searchFilterBar/index.js.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +9 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.js +2 -2
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.d.ts +52 -6
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.js +240 -31
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
- package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.js +132 -0
- package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
- package/dist/components/supportTicket/index.d.ts +2 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -1
- package/dist/components/supportTicket/index.js +2 -0
- package/dist/components/supportTicket/index.js.map +1 -1
- package/dist/components/supportTicket/meta.d.ts +8 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -1
- package/dist/components/supportTicket/meta.js +37 -1
- package/dist/components/supportTicket/meta.js.map +1 -1
- package/dist/components/supportTicket/ticketModel.d.ts +102 -0
- package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
- package/dist/components/supportTicket/ticketModel.js +3 -0
- package/dist/components/supportTicket/ticketModel.js.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.js +109 -78
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
- package/dist/components/ticketThread/TicketThread.js +19 -2
- package/dist/components/ticketThread/TicketThread.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +7 -0
- package/src/components/form/engine/FormEngine.stories.tsx +90 -0
- package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
- package/src/components/searchFilterBar/index.ts +1 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +37 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +11 -0
- package/src/components/supportTicket/ReferencePicker.stories.tsx +257 -34
- package/src/components/supportTicket/ReferencePicker.tsx +421 -95
- package/src/components/supportTicket/TicketConversation.stories.tsx +0 -1
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +0 -4
- package/src/components/supportTicket/attachmentAccess.ts +97 -0
- package/src/components/supportTicket/index.ts +2 -0
- package/src/components/supportTicket/meta.ts +40 -0
- package/src/components/supportTicket/ticketModel.ts +131 -0
- package/src/components/ticketReferences/TicketReferences.tsx +124 -145
- package/src/components/ticketThread/TicketThread.stories.tsx +24 -2
- package/src/components/ticketThread/TicketThread.tsx +23 -3
- package/src/index.tsx +3 -1
|
@@ -1,24 +1,67 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ReqoreInput,
|
|
3
|
+
ReqoreMenu,
|
|
4
|
+
ReqoreMenuItem,
|
|
5
|
+
ReqoreModal,
|
|
6
|
+
ReqoreSpinner,
|
|
7
|
+
} from '@qoretechnologies/reqore';
|
|
8
|
+
import { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button';
|
|
2
9
|
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
3
|
-
import { ReactNode, useMemo, useState } from 'react';
|
|
10
|
+
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
11
|
import styled from 'styled-components';
|
|
5
12
|
import { thinScrollbar } from '../../helpers/scrollbar';
|
|
6
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
defaultInterfaceIcon,
|
|
15
|
+
defaultInterfaceKindLabel,
|
|
16
|
+
IInterfaceReference,
|
|
17
|
+
} from './meta';
|
|
7
18
|
|
|
8
19
|
/*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
20
|
+
* The interface browser: kinds on one side, the selected kind's interfaces on the
|
|
21
|
+
* other, and one search field that scopes both. Sits above a ticket composer, toggled
|
|
22
|
+
* by the composer's "Reference interfaces" action.
|
|
12
23
|
*
|
|
13
24
|
* The host owns the data. Which kinds exist and what lives under one are questions only
|
|
14
25
|
* the surrounding app can answer — the IDE lists them off the customer's live instance,
|
|
15
26
|
* a story hands over fixtures — so this component takes `kinds` and the selected kind's
|
|
16
27
|
* `items` and does the rest: selection, search, and turning a click into a reference.
|
|
28
|
+
* Nothing here fetches; `kind` is the only contract, and the host maps it onto whatever
|
|
29
|
+
* transport it has.
|
|
30
|
+
*
|
|
31
|
+
* Three rules the layout follows, in order of how often they bite:
|
|
32
|
+
*
|
|
33
|
+
* - Search filters the kind list down to what matches, and keeps ONE extra row: the
|
|
34
|
+
* selected kind, dimmed, when the query doesn't match it. That row is what stops the
|
|
35
|
+
* right-hand results outliving the kind they belong to — without it you'd see three
|
|
36
|
+
* interfaces and no way to tell what they were. Everything else goes, because a match
|
|
37
|
+
* that survives only by scrolling past nine dimmed rows isn't a match the user found.
|
|
38
|
+
* - A picked interface STAYS in the list, selected. Dropping it on pick was a
|
|
39
|
+
* disappearing act with no undo inside the picker; now the row toggles.
|
|
40
|
+
* - Below `STACK_BREAKPOINT` of its OWN width — not the viewport's, since this thing
|
|
41
|
+
* lives inside composers and drawers — the two panes collapse into one column and the
|
|
42
|
+
* selected kind's interfaces render directly beneath it. On a phone it goes further
|
|
43
|
+
* and lifts into a modal, because inline it would own the whole screen.
|
|
17
44
|
*/
|
|
18
45
|
|
|
19
|
-
/** How
|
|
20
|
-
const
|
|
46
|
+
/** How tall the panes get before they scroll: side by side, stacked, and in the modal. */
|
|
47
|
+
const COLUMNS_MAX_HEIGHT = '220px';
|
|
48
|
+
const STACKED_MAX_HEIGHT = '280px';
|
|
49
|
+
const MODAL_MAX_HEIGHT = '60vh';
|
|
21
50
|
const KIND_PANE_WIDTH = 150;
|
|
51
|
+
/** Container width (not viewport) below which the panes stack into one column. */
|
|
52
|
+
const STACK_BREAKPOINT = 420;
|
|
53
|
+
const BORDER = 'rgba(123, 104, 238, 0.25)';
|
|
54
|
+
/*
|
|
55
|
+
* Marks the selected kind when the search doesn't match it — it's only still on the
|
|
56
|
+
* list to say what the interfaces on the right are. Quiet, but pickable.
|
|
57
|
+
*/
|
|
58
|
+
const DIMMED_CLASS = 'reqore-reference-picker-dimmed';
|
|
59
|
+
/*
|
|
60
|
+
* Marks an already-referenced row. Reqore paints `selected` purely through styled
|
|
61
|
+
* props — no class, no aria state — so "is this row picked" is unobservable from the
|
|
62
|
+
* outside without a marker of our own. Stable, so a host can style on it too.
|
|
63
|
+
*/
|
|
64
|
+
const PICKED_CLASS = 'reqore-reference-picker-picked';
|
|
22
65
|
|
|
23
66
|
/*
|
|
24
67
|
* A scroll pane with the thin, unobtrusive scrollbar the Qonsole surfaces use
|
|
@@ -26,22 +69,48 @@ const KIND_PANE_WIDTH = 150;
|
|
|
26
69
|
* webkit-only, like those surfaces: setting `scrollbar-width` as well would let
|
|
27
70
|
* Chromium override this custom 6px with its own, wider, "thin".
|
|
28
71
|
*/
|
|
29
|
-
const Pane = styled.div<{ $right?: boolean }>`
|
|
72
|
+
const Pane = styled.div<{ $right?: boolean; $maxHeight: string }>`
|
|
30
73
|
${({ $right }) =>
|
|
31
74
|
$right
|
|
32
75
|
? 'flex: 1; min-width: 0;'
|
|
33
|
-
: `width: ${KIND_PANE_WIDTH}px; flex: none; border-right: 1px solid
|
|
34
|
-
max-height: ${
|
|
76
|
+
: `width: ${KIND_PANE_WIDTH}px; flex: none; border-right: 1px solid ${BORDER};`}
|
|
77
|
+
max-height: ${({ $maxHeight }) => $maxHeight};
|
|
78
|
+
overflow-y: auto;
|
|
79
|
+
padding: 4px;
|
|
80
|
+
|
|
81
|
+
${thinScrollbar}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
/** The stacked counterpart of `Pane` — one column, so one scroll region. */
|
|
85
|
+
const Stack = styled.div<{ $maxHeight: string }>`
|
|
86
|
+
max-height: ${({ $maxHeight }) => $maxHeight};
|
|
35
87
|
overflow-y: auto;
|
|
36
88
|
padding: 4px;
|
|
37
89
|
|
|
38
90
|
${thinScrollbar}
|
|
39
91
|
`;
|
|
40
92
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
93
|
+
/*
|
|
94
|
+
* The selected kind's interfaces once the panes have collapsed. The indent and the
|
|
95
|
+
* rule are the only things left saying "these belong to the row above" — in the
|
|
96
|
+
* two-pane layout the divider between the panes says it.
|
|
97
|
+
*/
|
|
98
|
+
const Nested = styled.div`
|
|
99
|
+
margin: 2px 0 4px 10px;
|
|
100
|
+
padding-left: 8px;
|
|
101
|
+
border-left: 1px solid ${BORDER};
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
const Frame = styled.div<{ $bare?: boolean }>`
|
|
105
|
+
${({ $bare }) => ($bare ? '' : `border: 1px solid ${BORDER}; border-radius: 8px;`)}
|
|
44
106
|
overflow: hidden;
|
|
107
|
+
|
|
108
|
+
/* Rows the search doesn't match. Set here rather than per row so the rule is
|
|
109
|
+
stated once, and on the row itself (not a wrapper) so it can't disturb the
|
|
110
|
+
menu's own flex layout. */
|
|
111
|
+
.${DIMMED_CLASS} {
|
|
112
|
+
opacity: 0.35;
|
|
113
|
+
}
|
|
45
114
|
`;
|
|
46
115
|
|
|
47
116
|
const Panes = styled.div`
|
|
@@ -55,10 +124,15 @@ const Panes = styled.div`
|
|
|
55
124
|
* around the whole control. Suppressing that outline and brightening the underline
|
|
56
125
|
* instead keeps a focus cue without the box. The selector is scoped under this wrapper,
|
|
57
126
|
* so it outranks Reqore's without `!important`.
|
|
127
|
+
*
|
|
128
|
+
* `$active` (the field has a query) holds the underline at full accent, so a filtered
|
|
129
|
+
* list is legible as filtered at a glance — the same signal `intent='info'` carries on
|
|
130
|
+
* a plain filter control, spoken in this surface's own accent.
|
|
58
131
|
*/
|
|
59
|
-
const Search = styled.div<{ $accent: string }>`
|
|
132
|
+
const Search = styled.div<{ $accent: string; $active?: boolean }>`
|
|
60
133
|
padding: 6px 4px 4px;
|
|
61
|
-
border-bottom: 1px solid
|
|
134
|
+
border-bottom: 1px solid
|
|
135
|
+
${({ $accent, $active }) => ($active ? $accent : `${$accent}80`)};
|
|
62
136
|
transition: border-color 0.15s ease-out;
|
|
63
137
|
|
|
64
138
|
.reqore-control-wrapper:focus-within {
|
|
@@ -77,6 +151,49 @@ const Note = styled.div`
|
|
|
77
151
|
|
|
78
152
|
const DEFAULT_ACCENT = '#762f7e';
|
|
79
153
|
|
|
154
|
+
/*
|
|
155
|
+
* How a picked row reads: `info`, tinted rather than filled.
|
|
156
|
+
*
|
|
157
|
+
* Two Reqore details make this less obvious than `selected` + `intent`:
|
|
158
|
+
*
|
|
159
|
+
* - `selected` becomes `active` on the button, and the active branch paints a solid
|
|
160
|
+
* background from the intent while ignoring `minimal` and `transparent` outright.
|
|
161
|
+
* So `selected` can't be softened — it has to go, and `minimal` carries the state.
|
|
162
|
+
* - `ReqoreMenuItem` sets `transparent` on every row, and `transparent` short-circuits
|
|
163
|
+
* `minimal`'s tint to nothing. Turning it off for picked rows is what lets the tint
|
|
164
|
+
* land, and it's only spread when the row IS picked — a `transparent: undefined` on
|
|
165
|
+
* the others would override the menu item's own default and flatten them all.
|
|
166
|
+
*
|
|
167
|
+
* The point of the tint: a solid blue bar is too much weight for "this one's already
|
|
168
|
+
* in your list". The rows still up for choosing are what the user is reading, and they
|
|
169
|
+
* should stay the loudest thing in the pane.
|
|
170
|
+
*/
|
|
171
|
+
const pickedItemProps = (isPicked: boolean) =>
|
|
172
|
+
isPicked ? ({ intent: 'info', minimal: true, transparent: false } as const) : {};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One interface in the list. A bare string is the name and nothing else — the common
|
|
176
|
+
* case, and what a host that has only names can keep passing. The object form adds the
|
|
177
|
+
* two things a name alone can't disambiguate: a `description` second line (what the
|
|
178
|
+
* thing is for, an owning group, a path) and a `badge` (a version, a run count, a
|
|
179
|
+
* status tag). Search reads both the name and the description, since both are on screen.
|
|
180
|
+
*/
|
|
181
|
+
export interface IReferencePickerItem {
|
|
182
|
+
/** the interface name — this is what gets stored on the reference */
|
|
183
|
+
name: string;
|
|
184
|
+
/** a muted second line under the name */
|
|
185
|
+
description?: string;
|
|
186
|
+
/** trailing badge, before the add/remove icon */
|
|
187
|
+
badge?: TReqoreBadge | TReqoreBadge[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type TReferencePickerItem = string | IReferencePickerItem;
|
|
191
|
+
|
|
192
|
+
/** `auto` picks `stacked` from the picker's own measured width. */
|
|
193
|
+
export type TReferencePickerLayout = 'auto' | 'columns' | 'stacked';
|
|
194
|
+
/** `auto` lifts into a modal on a phone-sized viewport, when `onClose` is given. */
|
|
195
|
+
export type TReferencePickerPresentation = 'auto' | 'inline' | 'modal';
|
|
196
|
+
|
|
80
197
|
export interface IReferencePickerProps {
|
|
81
198
|
/** the interface kinds listed down the left pane */
|
|
82
199
|
kinds: string[];
|
|
@@ -84,10 +201,11 @@ export interface IReferencePickerProps {
|
|
|
84
201
|
kind: string;
|
|
85
202
|
onKindChange: (kind: string) => void;
|
|
86
203
|
/**
|
|
87
|
-
* The selected kind's interfaces
|
|
88
|
-
* where they come from and how
|
|
204
|
+
* The selected kind's interfaces — bare names, or objects carrying a description
|
|
205
|
+
* and a badge. Fetching is the host's job; it knows where they come from and how
|
|
206
|
+
* to cache them.
|
|
89
207
|
*/
|
|
90
|
-
items:
|
|
208
|
+
items: TReferencePickerItem[];
|
|
91
209
|
/** `items` is still being fetched, so the right pane shows a spinner rather than
|
|
92
210
|
* claiming the kind is empty */
|
|
93
211
|
loading?: boolean;
|
|
@@ -99,21 +217,95 @@ export interface IReferencePickerProps {
|
|
|
99
217
|
error?: string;
|
|
100
218
|
/** Retry the failed fetch. The error state is pressable when given. */
|
|
101
219
|
onRetry?: () => void;
|
|
102
|
-
/** Already-picked references. Anything of the current kind that is already picked
|
|
103
|
-
*
|
|
220
|
+
/** Already-picked references. Anything of the current kind that is already picked
|
|
221
|
+
* renders selected in place — never removed from the list. */
|
|
104
222
|
picked?: IInterfaceReference[];
|
|
105
223
|
onAdd: (reference: IInterfaceReference) => void;
|
|
224
|
+
/** Un-pick a reference from inside the picker. Without it a picked row still shows
|
|
225
|
+
* as picked, it just can't be toggled back off here. */
|
|
226
|
+
onRemove?: (reference: IInterfaceReference) => void;
|
|
106
227
|
/** the whole picker is inert (e.g. a reply is in flight) */
|
|
107
228
|
disabled?: boolean;
|
|
108
229
|
/** Per-kind icon. Consumers with their own icon vocabulary (the IDE) pass one; the
|
|
109
230
|
* built-in per-kind default is used when omitted. */
|
|
110
231
|
resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
|
|
232
|
+
/**
|
|
233
|
+
* Per-kind reading name — "Value maps" for `value-map`. The kind id is a wire value
|
|
234
|
+
* and never reaches the user; this is what does. Search matches both, so typing
|
|
235
|
+
* either the id or the label finds the kind. Defaults to the built-in Qorus
|
|
236
|
+
* vocabulary.
|
|
237
|
+
*/
|
|
238
|
+
resolveKindLabel?: (kind: string) => string;
|
|
111
239
|
/** the underline / selected-kind colour. Defaults to the support surfaces' violet. */
|
|
112
240
|
accent?: string;
|
|
113
241
|
/** shown above the panes — the composer's picked-reference chips, typically */
|
|
114
242
|
header?: ReactNode;
|
|
243
|
+
/**
|
|
244
|
+
* Dismiss the picker. Required for the modal presentation (a modal with no way out
|
|
245
|
+
* is a trap); the inline presentation ignores it, since the host's own toggle is
|
|
246
|
+
* what closes the picker there.
|
|
247
|
+
*/
|
|
248
|
+
onClose?: () => void;
|
|
249
|
+
/** Modal heading. */
|
|
250
|
+
title?: string;
|
|
251
|
+
/** How the panes arrange. `auto` (default) stacks below `STACK_BREAKPOINT` of the
|
|
252
|
+
* picker's own width; the explicit values are what stories pin. */
|
|
253
|
+
layout?: TReferencePickerLayout;
|
|
254
|
+
/** Where the picker renders. `auto` (default) goes to a modal on a phone-sized
|
|
255
|
+
* viewport when `onClose` is given, and stays inline otherwise. */
|
|
256
|
+
presentation?: TReferencePickerPresentation;
|
|
115
257
|
}
|
|
116
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Watches an element's own width, because the viewport's is the wrong question for
|
|
261
|
+
* anything that lives inside a composer, a drawer or a side panel — those stay narrow
|
|
262
|
+
* on a 2560px screen. A callback ref rather than `useRef` so the observer attaches the
|
|
263
|
+
* moment the node mounts; a ref that never gets attached silently reports "wide"
|
|
264
|
+
* forever, which is exactly the bug this is meant to catch.
|
|
265
|
+
*/
|
|
266
|
+
const useIsNarrow = (enabled: boolean): [(node: HTMLDivElement | null) => void, boolean] => {
|
|
267
|
+
const [node, setNode] = useState<HTMLDivElement | null>(null);
|
|
268
|
+
const [narrow, setNarrow] = useState<boolean>(false);
|
|
269
|
+
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!enabled || !node || typeof ResizeObserver === 'undefined') {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
276
|
+
setNarrow(entry.contentRect.width < STACK_BREAKPOINT);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
observer.observe(node);
|
|
280
|
+
|
|
281
|
+
return () => observer.disconnect();
|
|
282
|
+
}, [enabled, node]);
|
|
283
|
+
|
|
284
|
+
return [setNode, narrow];
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** Phone-sized viewport. Its own media query rather than reqore's `isMobile`, which
|
|
288
|
+
* is pinned to `false` under NODE_ENV=test and so never fires in a story run. */
|
|
289
|
+
const usePhoneViewport = (enabled: boolean): boolean => {
|
|
290
|
+
const [phone, setPhone] = useState<boolean>(false);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!enabled || typeof window === 'undefined' || !window.matchMedia) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const query = window.matchMedia('(max-width: 480px)');
|
|
298
|
+
const sync = () => setPhone(query.matches);
|
|
299
|
+
|
|
300
|
+
sync();
|
|
301
|
+
query.addEventListener('change', sync);
|
|
302
|
+
|
|
303
|
+
return () => query.removeEventListener('change', sync);
|
|
304
|
+
}, [enabled]);
|
|
305
|
+
|
|
306
|
+
return phone;
|
|
307
|
+
};
|
|
308
|
+
|
|
117
309
|
export const ReferencePicker = ({
|
|
118
310
|
kinds,
|
|
119
311
|
kind,
|
|
@@ -124,45 +316,205 @@ export const ReferencePicker = ({
|
|
|
124
316
|
onRetry,
|
|
125
317
|
picked,
|
|
126
318
|
onAdd,
|
|
319
|
+
onRemove,
|
|
127
320
|
disabled,
|
|
128
321
|
resolveInterfaceIcon = defaultInterfaceIcon,
|
|
322
|
+
resolveKindLabel = defaultInterfaceKindLabel,
|
|
129
323
|
accent = DEFAULT_ACCENT,
|
|
130
324
|
header,
|
|
325
|
+
onClose,
|
|
326
|
+
title = 'Reference interfaces',
|
|
327
|
+
layout = 'auto',
|
|
328
|
+
presentation = 'auto',
|
|
131
329
|
}: IReferencePickerProps) => {
|
|
132
330
|
const [query, setQuery] = useState<string>('');
|
|
133
331
|
const needle = query.trim().toLowerCase();
|
|
134
332
|
|
|
333
|
+
const [frameRef, narrow] = useIsNarrow(layout === 'auto');
|
|
334
|
+
const phone = usePhoneViewport(presentation === 'auto');
|
|
335
|
+
|
|
336
|
+
const asModal = presentation === 'modal' || (presentation === 'auto' && phone && !!onClose);
|
|
337
|
+
const stacked = layout === 'stacked' || (layout === 'auto' && narrow);
|
|
338
|
+
const label = resolveKindLabel(kind);
|
|
339
|
+
|
|
340
|
+
/* A kind matches on its id or its reading name, so "value map" and `value-map`
|
|
341
|
+
* both find it. */
|
|
342
|
+
const matchesKind = useCallback(
|
|
343
|
+
(candidate: string) =>
|
|
344
|
+
!needle ||
|
|
345
|
+
candidate.toLowerCase().includes(needle) ||
|
|
346
|
+
resolveKindLabel(candidate).toLowerCase().includes(needle),
|
|
347
|
+
[needle, resolveKindLabel]
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
/* The matches, plus the selected kind whether or not it matched — it keeps its
|
|
351
|
+
* natural position rather than jumping to the top, so the list a user is reading
|
|
352
|
+
* doesn't reorder under them mid-keystroke. */
|
|
135
353
|
const visibleKinds = useMemo(
|
|
136
|
-
() =>
|
|
137
|
-
[kinds,
|
|
354
|
+
() => kinds.filter((candidate) => candidate === kind || matchesKind(candidate)),
|
|
355
|
+
[kinds, kind, matchesKind]
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const pickedNames = useMemo(
|
|
359
|
+
() =>
|
|
360
|
+
new Set(
|
|
361
|
+
(picked ?? [])
|
|
362
|
+
.filter((reference) => reference.interface_kind === kind)
|
|
363
|
+
.map((reference) => reference.interface_name)
|
|
364
|
+
),
|
|
365
|
+
[picked, kind]
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
/** One shape from here down, whichever of the two the host passed. */
|
|
369
|
+
const normalizedItems = useMemo(
|
|
370
|
+
() => items.map((item) => (typeof item === 'string' ? { name: item } : item)),
|
|
371
|
+
[items]
|
|
138
372
|
);
|
|
139
373
|
|
|
140
374
|
/*
|
|
141
|
-
* The
|
|
142
|
-
*
|
|
143
|
-
*
|
|
375
|
+
* The interfaces the search leaves standing. When the KIND itself matches
|
|
376
|
+
* ("workflow"), its interfaces all stay — you searched for the kind, so you want to
|
|
377
|
+
* see what's in it. Otherwise the name and the description are both fair game,
|
|
378
|
+
* because both are text the user can see.
|
|
144
379
|
*/
|
|
145
380
|
const visibleItems = useMemo(() => {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
381
|
+
const kindMatches = matchesKind(kind);
|
|
382
|
+
|
|
383
|
+
return normalizedItems.filter(
|
|
384
|
+
(item) =>
|
|
385
|
+
!needle ||
|
|
386
|
+
kindMatches ||
|
|
387
|
+
item.name.toLowerCase().includes(needle) ||
|
|
388
|
+
!!item.description?.toLowerCase().includes(needle)
|
|
150
389
|
);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
390
|
+
}, [normalizedItems, kind, needle, matchesKind]);
|
|
391
|
+
|
|
392
|
+
/* Drives the "…but other types do" hint: with the kind list now always on screen,
|
|
393
|
+
* an empty result is only puzzling when the answer is sitting in another row. */
|
|
394
|
+
const otherKindsMatch = useMemo(
|
|
395
|
+
() => !!needle && kinds.some((candidate) => candidate !== kind && matchesKind(candidate)),
|
|
396
|
+
[kinds, kind, needle, matchesKind]
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const toggle = (name: string) => {
|
|
400
|
+
const reference: IInterfaceReference = { interface_kind: kind, interface_name: name };
|
|
401
|
+
|
|
402
|
+
if (pickedNames.has(name)) {
|
|
403
|
+
onRemove?.(reference);
|
|
404
|
+
} else {
|
|
405
|
+
onAdd(reference);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const emptyNote = () => {
|
|
410
|
+
if (!needle) {
|
|
411
|
+
return `No ${label.toLowerCase()} on this instance`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return otherKindsMatch
|
|
415
|
+
? `No ${label.toLowerCase()} match “${query.trim()}” — other types do`
|
|
416
|
+
: `Nothing matches “${query.trim()}”`;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/* Both panes are `ReqoreMenu`s of `ReqoreMenuItem`s — a vertical selectable list is
|
|
420
|
+
exactly what Menu is, so the selection (`selected`), the left-icon + right-icon,
|
|
421
|
+
and the item chrome come from reqore rather than hand-styled buttons. The wrappers
|
|
422
|
+
still own the scroll and the layout; the menus render transparent inside them.
|
|
423
|
+
Only menu items go inside a `ReqoreMenu`: it clones every child with its own props,
|
|
424
|
+
so a spinner or a note nested in one would be handed props it can't take. */
|
|
425
|
+
const kindItems = visibleKinds.map((option) => (
|
|
426
|
+
<ReqoreMenuItem
|
|
427
|
+
key={option}
|
|
428
|
+
className={matchesKind(option) ? undefined : DIMMED_CLASS}
|
|
429
|
+
icon={resolveInterfaceIcon(option)}
|
|
430
|
+
label={resolveKindLabel(option)}
|
|
431
|
+
selected={option === kind}
|
|
432
|
+
disabled={disabled}
|
|
433
|
+
onClick={() => onKindChange(option)}
|
|
434
|
+
/>
|
|
435
|
+
));
|
|
436
|
+
|
|
437
|
+
const interfaces = (() => {
|
|
438
|
+
if (error) {
|
|
439
|
+
return (
|
|
440
|
+
<ReqoreMenu transparent flat padded={false} width='100%' size='small'>
|
|
441
|
+
<ReqoreMenuItem
|
|
442
|
+
icon='ErrorWarningLine'
|
|
443
|
+
rightIcon={onRetry ? 'RefreshLine' : undefined}
|
|
444
|
+
intent='danger'
|
|
445
|
+
label={`Couldn't load ${label.toLowerCase()}`}
|
|
446
|
+
/* the specific failure is the tooltip — the pane states the outcome */
|
|
447
|
+
tooltip={error}
|
|
448
|
+
onClick={onRetry}
|
|
449
|
+
/>
|
|
450
|
+
</ReqoreMenu>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (loading) {
|
|
455
|
+
return (
|
|
456
|
+
<ReqoreSpinner iconColor='info' size='small' centered>
|
|
457
|
+
Loading {label.toLowerCase()}…
|
|
458
|
+
</ReqoreSpinner>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!visibleItems.length) {
|
|
463
|
+
return <Note>{emptyNote()}</Note>;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<ReqoreMenu transparent flat padded={false} width='100%' size='small'>
|
|
468
|
+
{visibleItems.map(({ name, description, badge }) => {
|
|
469
|
+
const isPicked = pickedNames.has(name);
|
|
470
|
+
const removable = isPicked && !!onRemove;
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<ReqoreMenuItem
|
|
474
|
+
key={name}
|
|
475
|
+
className={isPicked ? PICKED_CLASS : undefined}
|
|
476
|
+
icon={resolveInterfaceIcon(kind)}
|
|
477
|
+
rightIcon={isPicked ? 'CheckLine' : 'AddLine'}
|
|
478
|
+
label={name}
|
|
479
|
+
description={description}
|
|
480
|
+
badge={badge}
|
|
481
|
+
disabled={disabled}
|
|
482
|
+
tooltip={
|
|
483
|
+
isPicked
|
|
484
|
+
? removable
|
|
485
|
+
? `Remove “${name}”`
|
|
486
|
+
: `“${name}” is already referenced`
|
|
487
|
+
: `Reference “${name}”`
|
|
488
|
+
}
|
|
489
|
+
onClick={!isPicked || removable ? () => toggle(name) : undefined}
|
|
490
|
+
{...pickedItemProps(isPicked)}
|
|
491
|
+
/>
|
|
492
|
+
);
|
|
493
|
+
})}
|
|
494
|
+
</ReqoreMenu>
|
|
155
495
|
);
|
|
156
|
-
}
|
|
496
|
+
})();
|
|
157
497
|
|
|
158
|
-
|
|
159
|
-
|
|
498
|
+
const maxHeight = asModal
|
|
499
|
+
? MODAL_MAX_HEIGHT
|
|
500
|
+
: stacked
|
|
501
|
+
? STACKED_MAX_HEIGHT
|
|
502
|
+
: COLUMNS_MAX_HEIGHT;
|
|
503
|
+
|
|
504
|
+
/* Stacked: one column, with the selected kind's interfaces spliced in directly
|
|
505
|
+
under its row. A kind the host hasn't listed puts them at the end rather than
|
|
506
|
+
dropping them. */
|
|
507
|
+
const selectedIndex = visibleKinds.indexOf(kind);
|
|
508
|
+
const splitAt = selectedIndex === -1 ? visibleKinds.length : selectedIndex + 1;
|
|
509
|
+
|
|
510
|
+
const body = (
|
|
511
|
+
<Frame ref={frameRef} $bare={asModal}>
|
|
160
512
|
{header}
|
|
161
|
-
<Search $accent={accent}>
|
|
513
|
+
<Search $accent={accent} $active={!!needle}>
|
|
162
514
|
<ReqoreInput
|
|
163
515
|
value={query}
|
|
164
516
|
onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
|
|
165
|
-
placeholder='Search
|
|
517
|
+
placeholder='Search types and interfaces…'
|
|
166
518
|
icon='SearchLine'
|
|
167
519
|
minimal
|
|
168
520
|
flat
|
|
@@ -172,66 +524,40 @@ export const ReferencePicker = ({
|
|
|
172
524
|
onClearClick={query ? () => setQuery('') : undefined}
|
|
173
525
|
/>
|
|
174
526
|
</Search>
|
|
175
|
-
|
|
176
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{visibleKinds.length ? (
|
|
183
|
-
<ReqoreMenu transparent flat padded={false} width='100%' size='small'>
|
|
184
|
-
{visibleKinds.map((option) => (
|
|
185
|
-
<ReqoreMenuItem
|
|
186
|
-
key={option}
|
|
187
|
-
icon={resolveInterfaceIcon(option)}
|
|
188
|
-
label={option}
|
|
189
|
-
selected={option === kind}
|
|
190
|
-
disabled={disabled}
|
|
191
|
-
onClick={() => onKindChange(option)}
|
|
192
|
-
/>
|
|
193
|
-
))}
|
|
194
|
-
</ReqoreMenu>
|
|
195
|
-
) : (
|
|
196
|
-
<Note>No kinds</Note>
|
|
197
|
-
)}
|
|
198
|
-
</Pane>
|
|
199
|
-
<Pane $right>
|
|
200
|
-
{error ? (
|
|
527
|
+
{stacked ? (
|
|
528
|
+
<Stack $maxHeight={maxHeight}>
|
|
529
|
+
<ReqoreMenu transparent flat padded={false} width='100%' size='small'>
|
|
530
|
+
{kindItems.slice(0, splitAt)}
|
|
531
|
+
</ReqoreMenu>
|
|
532
|
+
<Nested>{interfaces}</Nested>
|
|
533
|
+
{splitAt < kindItems.length ? (
|
|
201
534
|
<ReqoreMenu transparent flat padded={false} width='100%' size='small'>
|
|
202
|
-
|
|
203
|
-
icon='ErrorWarningLine'
|
|
204
|
-
rightIcon={onRetry ? 'RefreshLine' : undefined}
|
|
205
|
-
intent='danger'
|
|
206
|
-
label={`Couldn't load ${kind}s`}
|
|
207
|
-
/* the specific failure is the tooltip — the pane states the outcome */
|
|
208
|
-
tooltip={error}
|
|
209
|
-
onClick={onRetry}
|
|
210
|
-
/>
|
|
535
|
+
{kindItems.slice(splitAt)}
|
|
211
536
|
</ReqoreMenu>
|
|
212
|
-
) :
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
537
|
+
) : null}
|
|
538
|
+
</Stack>
|
|
539
|
+
) : (
|
|
540
|
+
<Panes>
|
|
541
|
+
<Pane $maxHeight={maxHeight}>
|
|
217
542
|
<ReqoreMenu transparent flat padded={false} width='100%' size='small'>
|
|
218
|
-
{
|
|
219
|
-
<ReqoreMenuItem
|
|
220
|
-
key={name}
|
|
221
|
-
icon={resolveInterfaceIcon(kind)}
|
|
222
|
-
rightIcon='AddLine'
|
|
223
|
-
label={name}
|
|
224
|
-
disabled={disabled}
|
|
225
|
-
tooltip={`Reference ${kind} ${name}`}
|
|
226
|
-
onClick={() => onAdd({ interface_kind: kind, interface_name: name })}
|
|
227
|
-
/>
|
|
228
|
-
))}
|
|
543
|
+
{kindItems}
|
|
229
544
|
</ReqoreMenu>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
545
|
+
</Pane>
|
|
546
|
+
<Pane $right $maxHeight={maxHeight}>
|
|
547
|
+
{interfaces}
|
|
548
|
+
</Pane>
|
|
549
|
+
</Panes>
|
|
550
|
+
)}
|
|
235
551
|
</Frame>
|
|
236
552
|
);
|
|
553
|
+
|
|
554
|
+
if (!asModal) {
|
|
555
|
+
return body;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<ReqoreModal isOpen icon='LinksLine' label={title} width='100%' blur={3} onClose={onClose}>
|
|
560
|
+
{body}
|
|
561
|
+
</ReqoreModal>
|
|
562
|
+
);
|
|
237
563
|
};
|
|
@@ -156,7 +156,6 @@ export const RichHelpdeskReply: Story = {
|
|
|
156
156
|
// "References" label, the chips read as the referenced interfaces on their own
|
|
157
157
|
aboveInput: (
|
|
158
158
|
<InterfaceReferenceTags
|
|
159
|
-
size='tiny'
|
|
160
159
|
references={[{ interface_kind: 'connection', interface_name: 'sftp-partner' }]}
|
|
161
160
|
customTheme={{ main: 'custom1' }}
|
|
162
161
|
/>
|
|
@@ -203,7 +202,6 @@ export const AllReferenceTypes: Story = {
|
|
|
203
202
|
defaultText: 'This one references every interface kind.',
|
|
204
203
|
aboveInput: (
|
|
205
204
|
<InterfaceReferenceTags
|
|
206
|
-
size='tiny'
|
|
207
205
|
customTheme={{ main: 'custom1' }}
|
|
208
206
|
references={[
|
|
209
207
|
{ interface_kind: 'workflow', interface_name: 'order-sync:1.2' },
|
|
@@ -269,7 +267,6 @@ export const QogTriggerAppLogo: Story = {
|
|
|
269
267
|
defaultText: 'These qogs are referenced by their trigger apps.',
|
|
270
268
|
aboveInput: (
|
|
271
269
|
<InterfaceReferenceTags
|
|
272
|
-
size='tiny'
|
|
273
270
|
customTheme={{ main: 'custom1' }}
|
|
274
271
|
references={[
|
|
275
272
|
// resolved live from trigger_app (the customer/IDE path)
|
|
@@ -337,7 +334,6 @@ export const WithScreenshotAndReferences: Story = {
|
|
|
337
334
|
defaultFiles: [screenshotFile()],
|
|
338
335
|
aboveInput: (
|
|
339
336
|
<InterfaceReferenceTags
|
|
340
|
-
size='tiny'
|
|
341
337
|
customTheme={{ main: 'custom1' }}
|
|
342
338
|
references={[
|
|
343
339
|
{ interface_kind: 'connection', interface_name: 'sftp-partner' },
|