@flamingo-stack/openframe-frontend-core 0.0.717 → 0.0.719
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/{chunk-B5XU6KOA.js → chunk-2JLP2X2M.js} +4836 -5424
- package/dist/{chunk-47FISYO5.js → chunk-4D6NMPOO.js} +2 -2
- package/dist/{chunk-U3TP4UGA.cjs → chunk-4JIGEK7C.cjs} +8 -8
- package/dist/{chunk-2X2P3VHF.js → chunk-5U3KWQXP.js} +4 -4
- package/dist/{chunk-TYVS64YN.js → chunk-5YAGX5DY.js} +1 -1
- package/dist/{chunk-GEDY7HQI.cjs → chunk-6X3O67OL.cjs} +4 -0
- package/dist/{chunk-2EK7PA73.js → chunk-7BCY77W6.js} +5 -5
- package/dist/{chunk-X3RVDBXU.cjs → chunk-7BREFV7G.cjs} +19 -19
- package/dist/chunk-A6P6DHEZ.cjs +130 -0
- package/dist/{chunk-3MJPCKB6.js → chunk-AJHSQ5XK.js} +2 -2
- package/dist/{chunk-E6FLX4TX.js → chunk-CU6NWO2M.js} +6 -6
- package/dist/{chunk-6MZI5SQ4.js → chunk-D6PMVWDI.js} +3 -3
- package/dist/{chunk-2RKFMUNW.cjs → chunk-DFEPUYTF.cjs} +24 -21
- package/dist/{chunk-M2A5SC6H.cjs → chunk-E2V2EH2G.cjs} +2 -2
- package/dist/{chunk-WH5GJSID.js → chunk-EAHOB64X.js} +4 -4
- package/dist/{chunk-PD4W2VMH.js → chunk-FVQJBII6.js} +1 -1
- package/dist/{chunk-APJZBDQN.js → chunk-H5V7GRN5.js} +24 -21
- package/dist/{chunk-3RKBJT6K.cjs → chunk-H5VIMBRN.cjs} +2 -2
- package/dist/{chunk-NB6EWUDE.cjs → chunk-HCFNEKO3.cjs} +13 -13
- package/dist/{chunk-6T3BTD6Q.js → chunk-IWCPXJEE.js} +201 -327
- package/dist/{chunk-PPXR22YC.cjs → chunk-LCLDFMDF.cjs} +773 -61
- package/dist/{chunk-LANNTTKP.cjs → chunk-LOZLHG2U.cjs} +6 -6
- package/dist/{chunk-5MF3NMYC.cjs → chunk-NQVS4G7G.cjs} +581 -1169
- package/dist/{chunk-J3Q53TWW.cjs → chunk-NYWESHOQ.cjs} +22 -22
- package/dist/{chunk-FROXS52D.cjs → chunk-QBCDSTLE.cjs} +2 -2
- package/dist/{chunk-YBQQNDC7.js → chunk-RR6PY34K.js} +4 -0
- package/dist/{chunk-2AI2E3O2.cjs → chunk-SWAVO7BB.cjs} +27 -27
- package/dist/{chunk-3QAMMWV2.cjs → chunk-TDZ76NCB.cjs} +20 -20
- package/dist/{chunk-NQWJAOHO.js → chunk-TELAANY6.js} +1 -1
- package/dist/{chunk-FGKSAQR3.js → chunk-TTEFN6VO.js} +4 -4
- package/dist/{chunk-VYKFM2NB.cjs → chunk-WS7WRR5Z.cjs} +36 -36
- package/dist/{chunk-VZI5BA4L.js → chunk-XGHGGBM3.js} +1 -1
- package/dist/{chunk-NPAQSMRY.js → chunk-Y4MB25KW.js} +1103 -391
- package/dist/chunk-YYYVS7CX.js +130 -0
- package/dist/{chunk-OHLBO4LM.cjs → chunk-ZKNZ2CXW.cjs} +3 -3
- package/dist/{chunk-JO5TLJPM.cjs → chunk-ZXCZYMN4.cjs} +37 -163
- package/dist/{cloud-tenants-LNC26WY2.js → cloud-tenants-DNBPNQNP.js} +4 -3
- package/dist/{cloud-tenants-KDWTVHSI.cjs → cloud-tenants-FBUO66GW.cjs} +5 -4
- package/dist/components/case-studies/index.cjs +13 -12
- package/dist/components/case-studies/index.js +5 -4
- package/dist/components/contact/index.cjs +6 -5
- package/dist/components/contact/index.js +5 -4
- package/dist/components/docs/index.cjs +8 -7
- package/dist/components/docs/index.js +7 -6
- package/dist/components/embeds/index.cjs +6 -5
- package/dist/components/embeds/index.js +5 -4
- package/dist/components/faq/index.cjs +7 -6
- package/dist/components/faq/index.js +6 -5
- package/dist/components/features/index.cjs +9 -4
- package/dist/components/features/index.js +8 -3
- package/dist/components/features/remote-session/index.d.ts +2 -0
- package/dist/components/features/remote-session/index.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-desktop-view.d.ts +6 -2
- package/dist/components/features/remote-session/remote-desktop-view.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts +3 -3
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-ticket-modals.d.ts +29 -0
- package/dist/components/features/remote-session/remote-session-ticket-modals.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +51 -50
- package/dist/components/help-center-pages/index.js +15 -14
- package/dist/components/index.cjs +33 -28
- package/dist/components/index.js +21 -16
- package/dist/components/meeting-scheduler/index.cjs +25 -24
- package/dist/components/meeting-scheduler/index.js +4 -3
- package/dist/components/navigation/index.cjs +6 -5
- package/dist/components/navigation/index.js +5 -4
- package/dist/components/onboarding-guides/index.cjs +9 -8
- package/dist/components/onboarding-guides/index.js +8 -7
- package/dist/components/product-demo/index.cjs +7 -7
- package/dist/components/product-demo/index.js +7 -7
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/related-content/related-content-section.d.ts +6 -6
- package/dist/components/related-content/related-content-section.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +8 -7
- package/dist/components/tickets/index.js +7 -6
- package/dist/components/ui/autocomplete.d.ts +6 -0
- package/dist/components/ui/autocomplete.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +7 -6
- package/dist/components/ui/index.js +6 -5
- package/dist/{devices-ATHYJUVQ.cjs → devices-5A5TY4JD.cjs} +6 -5
- package/dist/{devices-NYCRQ3PG.js → devices-PLDAEUSL.js} +4 -3
- package/dist/hooks/index.cjs +6 -4
- package/dist/hooks/index.js +5 -3
- package/dist/hooks/use-self-fetch.d.ts +3 -0
- package/dist/hooks/use-self-fetch.d.ts.map +1 -1
- package/dist/index.cjs +14 -8
- package/dist/index.js +15 -9
- package/dist/{knowledge-EHNRYT7U.cjs → knowledge-FKTJ723Z.cjs} +5 -4
- package/dist/{knowledge-DEKYJXBS.js → knowledge-G6Q5KRWV.js} +4 -3
- package/dist/{logs-3V765P7O.cjs → logs-CJCXUBU6.cjs} +13 -12
- package/dist/{logs-Q7YFIVYI.js → logs-QMV6XIC4.js} +4 -3
- package/dist/{policies-BTIENK5A.js → policies-G6D7AX6F.js} +4 -3
- package/dist/{policies-TXU5AIXS.cjs → policies-QMRTK3CY.cjs} +9 -8
- package/dist/{remote-session-R6VAFESR.cjs → remote-session-2JVI7RVA.cjs} +4 -3
- package/dist/{remote-session-GSGZOFDX.js → remote-session-DBD6OSTX.js} +2 -1
- package/dist/{software-update-SW2HOBHW.js → software-update-6BQM6BKH.js} +6 -4
- package/dist/{software-update-MINTICZY.cjs → software-update-PNZMRDBT.cjs} +11 -9
- package/package.json +1 -1
- package/src/components/features/remote-session/index.ts +2 -0
- package/src/components/features/remote-session/remote-desktop-view.tsx +86 -63
- package/src/components/features/remote-session/remote-session-keep-modals.tsx +4 -4
- package/src/components/features/remote-session/remote-session-ticket-modals.tsx +140 -0
- package/src/components/related-content/related-content-section.tsx +46 -47
- package/src/components/ui/autocomplete.tsx +18 -4
- package/src/hooks/use-self-fetch.ts +7 -0
|
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
|
|
|
4
4
|
import {
|
|
5
5
|
ChatOffIcon,
|
|
6
6
|
ChatTextIcon,
|
|
7
|
+
Chevron02DownIcon,
|
|
7
8
|
Collapse02Icon,
|
|
8
9
|
Expand02Icon,
|
|
9
10
|
MonitorIcon,
|
|
@@ -19,8 +20,12 @@ import { RemoteDesktopFullscreenToolbar } from './remote-desktop-fullscreen-tool
|
|
|
19
20
|
/** The page chrome both the view and its skeleton render in. */
|
|
20
21
|
const PAGE_CLASS = 'h-full overflow-hidden px-[var(--spacing-system-l)] pb-[var(--spacing-system-l)]';
|
|
21
22
|
|
|
23
|
+
/** The header card: the device row, and under it the display row when there is one to pick. */
|
|
22
24
|
const CONTROLS_BAR_CLASS =
|
|
23
|
-
'flex flex-shrink-0
|
|
25
|
+
'flex flex-shrink-0 flex-col overflow-hidden rounded-md border border-ods-border bg-ods-card';
|
|
26
|
+
|
|
27
|
+
const CONTROLS_ROW_CLASS =
|
|
28
|
+
'flex items-center justify-between gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)] py-[var(--spacing-system-xs)]';
|
|
24
29
|
|
|
25
30
|
export interface RemoteDesktopViewProps {
|
|
26
31
|
/** First line of the device header. */
|
|
@@ -36,9 +41,13 @@ export interface RemoteDesktopViewProps {
|
|
|
36
41
|
fullscreen?: boolean;
|
|
37
42
|
onEnterFullscreen: () => void;
|
|
38
43
|
onExitFullscreen: () => void;
|
|
39
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The display switcher's menu, one entry per separate display. The switcher is
|
|
46
|
+
* the full-width row under the device row (Figma 2155:109503), and it is left
|
|
47
|
+
* out while this is empty: one display, or an agent that reports none.
|
|
48
|
+
*/
|
|
40
49
|
displayMenuGroups?: ActionsMenuGroup[];
|
|
41
|
-
/** The switcher's label, e.g. "Display
|
|
50
|
+
/** The switcher's label, e.g. "Display 2", or "Display" before the agent has named one. */
|
|
42
51
|
currentDisplayLabel: string;
|
|
43
52
|
actionsMenuGroups: ActionsMenuGroup[];
|
|
44
53
|
onOpenSettings: () => void;
|
|
@@ -86,61 +95,73 @@ export function RemoteDesktopView({
|
|
|
86
95
|
}: RemoteDesktopViewProps) {
|
|
87
96
|
const controlsBar = (
|
|
88
97
|
<div className={CONTROLS_BAR_CLASS}>
|
|
89
|
-
<div className=
|
|
90
|
-
<div className="flex-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
<div className={CONTROLS_ROW_CLASS}>
|
|
99
|
+
<div className="flex min-w-0 items-center gap-[var(--spacing-system-mf)]">
|
|
100
|
+
<div className="flex-shrink-0 rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-xsf)]">
|
|
101
|
+
<MonitorIcon className="h-4 w-4 text-ods-text-primary" />
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex min-w-0 flex-col">
|
|
104
|
+
<TruncateText>{deviceName}</TruncateText>
|
|
105
|
+
<TruncateText
|
|
106
|
+
variant="h6"
|
|
107
|
+
tone="secondary"
|
|
108
|
+
>{`Desktop • ${organizationName || 'Unknown Customer'}`}</TruncateText>
|
|
109
|
+
</div>
|
|
99
110
|
</div>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
<div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
|
|
112
|
+
{onToggleChat && (
|
|
113
|
+
<Button
|
|
114
|
+
variant="outline"
|
|
115
|
+
onClick={onToggleChat}
|
|
116
|
+
leftIcon={
|
|
117
|
+
chatOpen ? (
|
|
118
|
+
<ChatOffIcon className="h-4 w-4 md:h-6 md:w-6" />
|
|
119
|
+
) : (
|
|
120
|
+
<ChatTextIcon className="h-4 w-4 md:h-6 md:w-6" />
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
{/* A narrow page keeps the icon and drops the words: the bar never pushes the device's name out. */}
|
|
125
|
+
<span className="sr-only content-md:not-sr-only">{chatOpen ? 'Close Chat' : 'Open Chat'}</span>
|
|
126
|
+
</Button>
|
|
127
|
+
)}
|
|
128
|
+
<ActionsMenuDropdown groups={actionsMenuGroups} triggerAriaLabel="Actions" />
|
|
129
|
+
<Button
|
|
130
|
+
variant="outline"
|
|
131
|
+
size="icon"
|
|
132
|
+
aria-label="Settings"
|
|
133
|
+
onClick={onOpenSettings}
|
|
134
|
+
leftIcon={<Settings01Icon />}
|
|
111
135
|
/>
|
|
112
|
-
)}
|
|
113
|
-
{onToggleChat && (
|
|
114
136
|
<Button
|
|
115
137
|
variant="outline"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
}
|
|
124
|
-
>
|
|
125
|
-
<span className="sr-only content-md:not-sr-only">{chatOpen ? 'Close Chat' : 'Open Chat'}</span>
|
|
126
|
-
</Button>
|
|
127
|
-
)}
|
|
128
|
-
<ActionsMenuDropdown groups={actionsMenuGroups} triggerAriaLabel="Actions" />
|
|
129
|
-
<Button
|
|
130
|
-
variant="outline"
|
|
131
|
-
size="icon"
|
|
132
|
-
aria-label="Settings"
|
|
133
|
-
onClick={onOpenSettings}
|
|
134
|
-
leftIcon={<Settings01Icon />}
|
|
135
|
-
/>
|
|
136
|
-
<Button
|
|
137
|
-
variant="outline"
|
|
138
|
-
size="icon"
|
|
139
|
-
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
140
|
-
onClick={fullscreen ? onExitFullscreen : onEnterFullscreen}
|
|
141
|
-
leftIcon={fullscreen ? <Collapse02Icon /> : <Expand02Icon />}
|
|
142
|
-
/>
|
|
138
|
+
size="icon"
|
|
139
|
+
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
140
|
+
onClick={fullscreen ? onExitFullscreen : onEnterFullscreen}
|
|
141
|
+
leftIcon={fullscreen ? <Collapse02Icon /> : <Expand02Icon />}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
143
144
|
</div>
|
|
145
|
+
{/* The monitor selector is a row of its own under the device row, the full
|
|
146
|
+
width of the card (Figma 2155:109503): the label never competes with the
|
|
147
|
+
device's name for the header's width. Hidden with one display or none. */}
|
|
148
|
+
{displayMenuGroups.length > 0 && (
|
|
149
|
+
<ActionsMenuDropdown
|
|
150
|
+
groups={displayMenuGroups}
|
|
151
|
+
align="start"
|
|
152
|
+
customTrigger={
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
aria-label="Switch display"
|
|
156
|
+
className="flex w-full items-center gap-[var(--spacing-system-xs)] border-t border-ods-border p-[var(--spacing-system-sf)] text-left text-ods-text-primary outline-none transition-colors hover:bg-ods-bg-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus"
|
|
157
|
+
>
|
|
158
|
+
<MonitorIcon className="h-6 w-6 shrink-0" />
|
|
159
|
+
<span className="min-w-0 flex-1 truncate text-h4">{currentDisplayLabel}</span>
|
|
160
|
+
<Chevron02DownIcon className="h-6 w-6 shrink-0" />
|
|
161
|
+
</button>
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
144
165
|
</div>
|
|
145
166
|
);
|
|
146
167
|
|
|
@@ -189,17 +210,19 @@ export function RemoteDesktopViewSkeleton({ onBack }: Pick<RemoteDesktopViewProp
|
|
|
189
210
|
return (
|
|
190
211
|
<PageLayout className={PAGE_CLASS} backButton={onBack ? { label: 'Back', onClick: onBack } : undefined}>
|
|
191
212
|
<div className={CONTROLS_BAR_CLASS}>
|
|
192
|
-
<div className=
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
<
|
|
196
|
-
|
|
213
|
+
<div className={CONTROLS_ROW_CLASS}>
|
|
214
|
+
<div className="flex min-w-0 items-center gap-[var(--spacing-system-mf)]">
|
|
215
|
+
<Skeleton className="h-9 w-9 flex-shrink-0 rounded-md" />
|
|
216
|
+
<div className="flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]">
|
|
217
|
+
<Skeleton className="h-5 w-48" />
|
|
218
|
+
<Skeleton className="h-4 w-36" />
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
|
|
222
|
+
<Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
|
|
223
|
+
<Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
|
|
224
|
+
<Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
|
|
197
225
|
</div>
|
|
198
|
-
</div>
|
|
199
|
-
<div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
|
|
200
|
-
<Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
|
|
201
|
-
<Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
|
|
202
|
-
<Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
|
|
203
226
|
</div>
|
|
204
227
|
</div>
|
|
205
228
|
|
|
@@ -151,8 +151,8 @@ export interface ReleaseKeepingModalProps {
|
|
|
151
151
|
keptOn: string;
|
|
152
152
|
/** The reason as it reads in the sentence, e.g. "Client dispute". */
|
|
153
153
|
reason: string;
|
|
154
|
-
/** The
|
|
155
|
-
|
|
154
|
+
/** The tickets assigned to the recording, as they read in the line, e.g. "TKT-4631"; the line is left out without any. */
|
|
155
|
+
tickets?: string[];
|
|
156
156
|
/** When the recording will be deleted once released, formatted. */
|
|
157
157
|
expiresOn: string;
|
|
158
158
|
/** The original expiry date when it has already passed, so the release adds the grace period. */
|
|
@@ -169,7 +169,7 @@ export function ReleaseKeepingModal({
|
|
|
169
169
|
keptBy,
|
|
170
170
|
keptOn,
|
|
171
171
|
reason,
|
|
172
|
-
|
|
172
|
+
tickets = [],
|
|
173
173
|
expiresOn,
|
|
174
174
|
dueOn,
|
|
175
175
|
isPending = false,
|
|
@@ -185,7 +185,7 @@ export function ReleaseKeepingModal({
|
|
|
185
185
|
<p>
|
|
186
186
|
Kept by {keptBy} on {keptOn} for {reason}.
|
|
187
187
|
</p>
|
|
188
|
-
{
|
|
188
|
+
{tickets.length > 0 && <p>Ticket: {tickets.join(', ')}</p>}
|
|
189
189
|
</div>
|
|
190
190
|
|
|
191
191
|
<KeepNotice icon={<HourglassIcon size={24} color="currentColor" />} title={`It will expire on ${expiresOn}`}>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Autocomplete, type AutocompleteOption } from '../../ui/autocomplete';
|
|
5
|
+
import { Button } from '../../ui/button/button';
|
|
6
|
+
import { ModalV2, ModalV2Footer, ModalV2Header, ModalV2Title } from '../../ui/modal-v2';
|
|
7
|
+
|
|
8
|
+
export interface AssignTicketModalProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* The tickets that can be assigned: label = title, value = ticket id. Leave
|
|
13
|
+
* out the ones already assigned to the recording.
|
|
14
|
+
*/
|
|
15
|
+
options: AutocompleteOption<string>[];
|
|
16
|
+
/** Server-side search: called with the typed text; the caller refreshes `options`. Client-side filtering otherwise. */
|
|
17
|
+
onSearch?: (query: string) => void;
|
|
18
|
+
/** The ticket search is in flight. */
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
/** The assignment is in flight: the button spins and the dialog refuses to close. */
|
|
21
|
+
isPending?: boolean;
|
|
22
|
+
onConfirm: (ticketIds: string[]) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Assign Ticket: links one or more tickets to a recording. */
|
|
26
|
+
export function AssignTicketModal({
|
|
27
|
+
isOpen,
|
|
28
|
+
onClose,
|
|
29
|
+
options,
|
|
30
|
+
onSearch,
|
|
31
|
+
loading = false,
|
|
32
|
+
isPending = false,
|
|
33
|
+
onConfirm,
|
|
34
|
+
}: AssignTicketModalProps) {
|
|
35
|
+
const [ticketIds, setTicketIds] = useState<string[]>([]);
|
|
36
|
+
// Titles of the picked tickets: a server-side search replaces `options`, and
|
|
37
|
+
// a picked ticket missing from the new results would otherwise show its id.
|
|
38
|
+
const [titles, setTitles] = useState<Record<string, string>>({});
|
|
39
|
+
|
|
40
|
+
// Every opening starts blank. Reset while rendering, not from an effect: the
|
|
41
|
+
// modal stays mounted when it closes, so an effect would paint the opening
|
|
42
|
+
// frame with the previous selection still in the field.
|
|
43
|
+
const [seededOpen, setSeededOpen] = useState(isOpen);
|
|
44
|
+
if (seededOpen !== isOpen) {
|
|
45
|
+
setSeededOpen(isOpen);
|
|
46
|
+
if (isOpen) {
|
|
47
|
+
setTicketIds([]);
|
|
48
|
+
setTitles({});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleChange = (ids: string[]) => {
|
|
53
|
+
setTicketIds(ids);
|
|
54
|
+
setTitles(prev => {
|
|
55
|
+
const next: Record<string, string> = {};
|
|
56
|
+
for (const id of ids) next[id] = options.find(option => option.value === id)?.label ?? prev[id] ?? id;
|
|
57
|
+
return next;
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleAssign = () => {
|
|
62
|
+
if (ticketIds.length === 0 || isPending) return;
|
|
63
|
+
onConfirm(ticketIds);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
|
|
68
|
+
<ModalV2Header>
|
|
69
|
+
<ModalV2Title>Assign Ticket</ModalV2Title>
|
|
70
|
+
</ModalV2Header>
|
|
71
|
+
|
|
72
|
+
<Autocomplete
|
|
73
|
+
multiple
|
|
74
|
+
wrapTags
|
|
75
|
+
label="Tickets"
|
|
76
|
+
labelVariant="large"
|
|
77
|
+
options={options}
|
|
78
|
+
value={ticketIds}
|
|
79
|
+
onChange={handleChange}
|
|
80
|
+
renderTag={option => titles[option.value] ?? option.label}
|
|
81
|
+
onInputChange={onSearch ? (value, reason) => reason === 'input' && onSearch(value) : undefined}
|
|
82
|
+
disableClientFilter={Boolean(onSearch)}
|
|
83
|
+
loading={loading}
|
|
84
|
+
noOptionsText="No tickets found"
|
|
85
|
+
disabled={isPending}
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<ModalV2Footer>
|
|
89
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
|
|
90
|
+
Cancel
|
|
91
|
+
</Button>
|
|
92
|
+
<div className="hidden flex-1 md:block" />
|
|
93
|
+
<Button
|
|
94
|
+
type="button"
|
|
95
|
+
variant="accent"
|
|
96
|
+
onClick={handleAssign}
|
|
97
|
+
loading={isPending}
|
|
98
|
+
disabled={ticketIds.length === 0}
|
|
99
|
+
className="flex-1"
|
|
100
|
+
>
|
|
101
|
+
Assign
|
|
102
|
+
</Button>
|
|
103
|
+
</ModalV2Footer>
|
|
104
|
+
</ModalV2>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface UnassignTicketModalProps {
|
|
109
|
+
isOpen: boolean;
|
|
110
|
+
onClose: () => void;
|
|
111
|
+
/** The unassignment is in flight: the button spins and the dialog refuses to close. */
|
|
112
|
+
isPending?: boolean;
|
|
113
|
+
onConfirm: () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Unassign Ticket: unlinks a ticket from a recording; the ticket itself is unchanged. */
|
|
117
|
+
export function UnassignTicketModal({ isOpen, onClose, isPending = false, onConfirm }: UnassignTicketModalProps) {
|
|
118
|
+
return (
|
|
119
|
+
<ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
|
|
120
|
+
<ModalV2Header>
|
|
121
|
+
<ModalV2Title>Unassign Ticket</ModalV2Title>
|
|
122
|
+
</ModalV2Header>
|
|
123
|
+
|
|
124
|
+
<p className="w-full text-ods-text-primary text-h4">
|
|
125
|
+
This Ticket will no longer be linked to this remote session. The ticket itself stays as it is, and you can
|
|
126
|
+
assign it again anytime.
|
|
127
|
+
</p>
|
|
128
|
+
|
|
129
|
+
<ModalV2Footer>
|
|
130
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
|
|
131
|
+
Cancel
|
|
132
|
+
</Button>
|
|
133
|
+
<div className="hidden flex-1 md:block" />
|
|
134
|
+
<Button type="button" variant="destructive" onClick={onConfirm} loading={isPending} className="flex-1">
|
|
135
|
+
Unassign
|
|
136
|
+
</Button>
|
|
137
|
+
</ModalV2Footer>
|
|
138
|
+
</ModalV2>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
* `renderSkeletonForType` so the placeholder height matches the loaded card
|
|
29
29
|
* exactly (zero layout shift on resolve).
|
|
30
30
|
*
|
|
31
|
-
* One API call per content type
|
|
32
|
-
* (`buildListUrl` — injectable; defaults to the
|
|
33
|
-
* builder prefixed with `apiBaseUrl`). Fetching uses
|
|
34
|
-
* fetch, NO react-query) so third-party embedders need
|
|
35
|
-
* cards are imported via DEEP module paths (not the
|
|
36
|
-
* chunk never reaches `@tanstack/react-query`.
|
|
31
|
+
* One API call per page of a content type, carrying only that page's ids, via
|
|
32
|
+
* the shared list-URL builder (`buildListUrl` — injectable; defaults to the
|
|
33
|
+
* lib's byte-parity-tested builder prefixed with `apiBaseUrl`). Fetching uses
|
|
34
|
+
* `useSelfFetch` (plain fetch, NO react-query) so third-party embedders need
|
|
35
|
+
* no QueryClientProvider; cards are imported via DEEP module paths (not the
|
|
36
|
+
* chat barrel) so this chunk never reaches `@tanstack/react-query`.
|
|
37
37
|
*
|
|
38
38
|
* The per-type card + skeleton dispatch lives in `./card-registry`
|
|
39
39
|
* (`RELATED_CARD_REGISTRY`): ONE entry per content type, each carrying that
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
51
|
import type React from 'react';
|
|
52
|
-
import {
|
|
52
|
+
import { useMemo, useState } from 'react';
|
|
53
53
|
import { useSelfFetch } from '../../hooks/use-self-fetch';
|
|
54
54
|
import type { ContentRef, ContentRefWithReason } from '../../types/content-ref';
|
|
55
55
|
import {
|
|
@@ -246,23 +246,29 @@ function CardForType({
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
// =============================================================================
|
|
249
|
-
// Fetch
|
|
250
|
-
//
|
|
251
|
-
// react-query usage: `enabled`
|
|
252
|
-
// `
|
|
253
|
-
// react-query: no retry/backoff, no focus
|
|
249
|
+
// Fetch ONE page of a group — only the ids on screen go into the list URL.
|
|
250
|
+
// Pages already fetched are kept by url, so paging back renders without a
|
|
251
|
+
// request. `useSelfFetch` replaces the hub's old react-query usage: `enabled`
|
|
252
|
+
// ≙ `url === null` (no list API → `items: null`), `!res.ok`/network error ≙
|
|
253
|
+
// an empty page. Accepted deltas vs react-query: no retry/backoff, no focus
|
|
254
|
+
// refetch, no cross-mount cache.
|
|
254
255
|
// =============================================================================
|
|
255
256
|
|
|
256
257
|
function useGroupItems(type: string, refs: ContentRef[], buildUrl: (type: string, ids: string[]) => string | null) {
|
|
257
258
|
const ids = refs.map(r => r.id);
|
|
258
259
|
const url = ids.length > 0 ? buildUrl(type, ids) : null;
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
260
|
+
const [pages, setPages] = useState<ReadonlyMap<string, unknown[]>>(() => new Map());
|
|
261
|
+
const cached = url !== null ? pages.get(url) : undefined;
|
|
262
|
+
const { data, dataUrl, error } = useSelfFetch<unknown>(cached ? null : url);
|
|
263
|
+
if (data != null && dataUrl !== null && !pages.has(dataUrl)) {
|
|
264
|
+
setPages(new Map(pages).set(dataUrl, extractItems(data)));
|
|
265
|
+
}
|
|
266
|
+
if (url === null) return { items: null, isLoading: false };
|
|
267
|
+
return { items: cached ?? (error ? [] : null), isLoading: !cached && !error };
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
// =============================================================================
|
|
265
|
-
// Per-group renderer — one API call
|
|
271
|
+
// Per-group renderer — one API call per page, then render cards via the
|
|
266
272
|
// dispatcher with per-type skeletons + per-type layout from CONTENT_REF_GROUPS.
|
|
267
273
|
// =============================================================================
|
|
268
274
|
|
|
@@ -325,41 +331,33 @@ function ContentGroup({
|
|
|
325
331
|
* its heading too — no orphaned titles. */
|
|
326
332
|
heading: React.ReactNode;
|
|
327
333
|
}) {
|
|
328
|
-
const { items, isLoading } = useGroupItems(type, refs, buildUrl);
|
|
329
334
|
const config = resolveGroupConfig(type);
|
|
330
335
|
const isListLayout = config.layout === 'list';
|
|
331
336
|
const cardSize = config.gridSize;
|
|
332
337
|
|
|
333
338
|
// Per-group pagination for big groups (author pages): GROUP_PAGE_SIZE items
|
|
334
|
-
// per page with the standard Pagination control below the group
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// refetch) can never strand the view past the last page, and RESET when the
|
|
339
|
-
// ref set actually changes (shrink→grow must not return to a stale page).
|
|
339
|
+
// per page with the standard Pagination control below the group, each page
|
|
340
|
+
// fetched on its own. Back to page 1 whenever the ref set changes (a
|
|
341
|
+
// suggestion refetch can shrink it) — reset while rendering, so the stale
|
|
342
|
+
// page is never requested.
|
|
340
343
|
const [page, setPage] = useState(1);
|
|
341
344
|
const refsKey = refs.map(r => r.id).join('|');
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
}, [refsKey]);
|
|
345
|
+
const [pagedRefsKey, setPagedRefsKey] = useState(refsKey);
|
|
346
|
+
if (pagedRefsKey !== refsKey) {
|
|
347
|
+
setPagedRefsKey(refsKey);
|
|
348
|
+
setPage(1);
|
|
349
|
+
}
|
|
349
350
|
const totalGroupPages = pageCount(refs.length, GROUP_PAGE_SIZE);
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
refs.length > GROUP_PAGE_SIZE ? refs.slice((safePage - 1) * GROUP_PAGE_SIZE, safePage * GROUP_PAGE_SIZE) : refs;
|
|
351
|
+
const pageRefs = refs.slice((page - 1) * GROUP_PAGE_SIZE, page * GROUP_PAGE_SIZE);
|
|
352
|
+
const { items, isLoading } = useGroupItems(type, pageRefs, buildUrl);
|
|
353
353
|
const groupPagination =
|
|
354
|
-
totalGroupPages > 1 ?
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (isLoading && !items) {
|
|
362
|
-
const skeletons = visibleGroupRefs.map(r => (
|
|
354
|
+
totalGroupPages > 1 ? <Pagination currentPage={page} totalPages={totalGroupPages} onPageChange={setPage} /> : null;
|
|
355
|
+
|
|
356
|
+
// Skeletons until this page's rows land — SSR HTML and the client's first
|
|
357
|
+
// paint render identical skeletons (useSelfFetch starts loading on both
|
|
358
|
+
// sides). The pager stays in place while an unfetched page loads.
|
|
359
|
+
if (isLoading) {
|
|
360
|
+
const skeletons = pageRefs.map(r => (
|
|
363
361
|
<div key={r.id}>{renderSkeletonForType(type, cardSize, adminCampaignCard)}</div>
|
|
364
362
|
));
|
|
365
363
|
return (
|
|
@@ -370,11 +368,12 @@ function ContentGroup({
|
|
|
370
368
|
) : (
|
|
371
369
|
<div className={gridClassFor(columns)}>{skeletons}</div>
|
|
372
370
|
)}
|
|
371
|
+
{groupPagination}
|
|
373
372
|
</div>
|
|
374
373
|
);
|
|
375
374
|
}
|
|
376
375
|
|
|
377
|
-
if (!items
|
|
376
|
+
if (!items) return null;
|
|
378
377
|
|
|
379
378
|
// Index fetched rows by id, then render in REF order — refs carry the
|
|
380
379
|
// intended sequence (suggestion mode: the engine's tier order, so
|
|
@@ -393,7 +392,7 @@ function ContentGroup({
|
|
|
393
392
|
};
|
|
394
393
|
const itemById = new Map(items.map(it => [rowKey(it), it]));
|
|
395
394
|
|
|
396
|
-
const cards =
|
|
395
|
+
const cards = pageRefs
|
|
397
396
|
.map(contentRef => {
|
|
398
397
|
const itemId = String(contentRef.id);
|
|
399
398
|
const item = itemById.get(itemId);
|
|
@@ -426,9 +425,9 @@ function ContentGroup({
|
|
|
426
425
|
.filter(Boolean);
|
|
427
426
|
|
|
428
427
|
if (cards.length === 0) {
|
|
429
|
-
// Current PAGE resolved zero cards (
|
|
430
|
-
// and the
|
|
431
|
-
// pager exists the user must keep the controls
|
|
428
|
+
// Current PAGE resolved zero cards (its fetch failed, rows were deleted
|
|
429
|
+
// between the ref fetch and the page fetch, or a stricter list-API gate
|
|
430
|
+
// dropped them). When a pager exists the user must keep the controls —
|
|
432
431
|
// dropping the whole group would strand them. A genuinely empty group
|
|
433
432
|
// (no pager) still vanishes with its heading.
|
|
434
433
|
if (groupPagination) {
|
|
@@ -140,6 +140,12 @@ export interface AutocompleteMultipleProps<T = string> extends AutocompleteBaseP
|
|
|
140
140
|
limitTags?: number | 'auto';
|
|
141
141
|
/** Custom render function for the "+N" overflow chip */
|
|
142
142
|
getLimitTagsText?: (more: number) => ReactNode;
|
|
143
|
+
/**
|
|
144
|
+
* Show every tag in full, wrapping onto more lines: the field grows instead
|
|
145
|
+
* of truncating labels and collapsing the rest into "+N". `limitTags` is
|
|
146
|
+
* ignored. For short pickers whose picks must stay readable (ticket titles).
|
|
147
|
+
*/
|
|
148
|
+
wrapTags?: boolean;
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
export type AutocompleteProps<T = string> = AutocompleteSingleProps<T> | AutocompleteMultipleProps<T>;
|
|
@@ -189,6 +195,7 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
|
|
|
189
195
|
// Multiple-only props
|
|
190
196
|
const maxItems = multiple ? (props as AutocompleteMultipleProps<T>).maxItems : undefined;
|
|
191
197
|
const renderTag = multiple ? (props as AutocompleteMultipleProps<T>).renderTag : undefined;
|
|
198
|
+
const wrapTags = multiple && ((props as AutocompleteMultipleProps<T>).wrapTags ?? false);
|
|
192
199
|
const limitTagsProp = multiple ? ((props as AutocompleteMultipleProps<T>).limitTags ?? 'auto') : 'auto';
|
|
193
200
|
const getLimitTagsText = multiple
|
|
194
201
|
? ((props as AutocompleteMultipleProps<T>).getLimitTagsText ?? ((more: number) => `+${more}`))
|
|
@@ -252,7 +259,7 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
|
|
|
252
259
|
inputRef,
|
|
253
260
|
} = useAutoLimitTags({
|
|
254
261
|
count: multiple ? selectedOptions.length : 0,
|
|
255
|
-
limitTags: multiple ? limitTagsProp : 0,
|
|
262
|
+
limitTags: multiple ? (wrapTags ? selectedOptions.length : limitTagsProp) : 0,
|
|
256
263
|
placeholder: inputPlaceholder,
|
|
257
264
|
});
|
|
258
265
|
|
|
@@ -506,15 +513,22 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
|
|
|
506
513
|
</span>
|
|
507
514
|
)}
|
|
508
515
|
|
|
509
|
-
{/* Middle zone: tags + input — single line with overflow */}
|
|
510
|
-
<div
|
|
516
|
+
{/* Middle zone: tags + input — single line with overflow, or wrapped lines with `wrapTags` */}
|
|
517
|
+
<div
|
|
518
|
+
ref={middleRef}
|
|
519
|
+
className={cn(
|
|
520
|
+
'flex min-w-0 flex-1 items-center gap-2 px-2',
|
|
521
|
+
wrapTags ? 'flex-wrap py-[var(--spacing-system-xsf)]' : 'overflow-hidden',
|
|
522
|
+
)}
|
|
523
|
+
>
|
|
511
524
|
{/* Tags (multiple mode only) */}
|
|
512
525
|
{multiple &&
|
|
513
526
|
visibleTags.map(option => (
|
|
514
527
|
<Tag
|
|
515
528
|
key={String(option.value)}
|
|
516
529
|
variant="outline"
|
|
517
|
-
|
|
530
|
+
className={wrapTags ? 'max-w-full' : undefined}
|
|
531
|
+
labelClassName={wrapTags ? 'truncate' : 'max-w-[90px] truncate'}
|
|
518
532
|
label={renderTag ? renderTag(option) : option.label}
|
|
519
533
|
onClose={
|
|
520
534
|
!disabled
|
|
@@ -5,6 +5,9 @@ import { contentFetch } from '../utils/embed-content-fetch';
|
|
|
5
5
|
|
|
6
6
|
export interface UseSelfFetchResult<T> {
|
|
7
7
|
data: T | null;
|
|
8
|
+
/** The url `data` came from (`null` until something lands). While a new url
|
|
9
|
+
* loads, `data` still holds the previous one — compare before trusting it. */
|
|
10
|
+
dataUrl: string | null;
|
|
8
11
|
/** Imperatively patch the fetched data (e.g. optimistic vote updates). */
|
|
9
12
|
setData: Dispatch<SetStateAction<T | null>>;
|
|
10
13
|
isLoading: boolean;
|
|
@@ -48,6 +51,7 @@ export function useSelfFetch<T>(
|
|
|
48
51
|
const initialData = options?.initialData;
|
|
49
52
|
const revalidateOnVisibleAfterMs = options?.revalidateOnVisibleAfterMs;
|
|
50
53
|
const [data, setData] = useState<T | null>(initialData ?? null);
|
|
54
|
+
const [dataUrl, setDataUrl] = useState<string | null>(initialData !== undefined ? url : null);
|
|
51
55
|
const [isLoading, setIsLoading] = useState(initialData === undefined && url !== null);
|
|
52
56
|
const [error, setError] = useState(false);
|
|
53
57
|
const [reloadKey, setReloadKey] = useState(0);
|
|
@@ -78,6 +82,7 @@ export function useSelfFetch<T>(
|
|
|
78
82
|
// `initialData` is `undefined`.
|
|
79
83
|
if (syncedProps.initialData !== initialData && initialData !== undefined) {
|
|
80
84
|
setData(initialData);
|
|
85
|
+
setDataUrl(url);
|
|
81
86
|
}
|
|
82
87
|
// Fetching just got disabled — nothing will land to clear the spinner.
|
|
83
88
|
if (syncedProps.url !== url && url === null) {
|
|
@@ -101,6 +106,7 @@ export function useSelfFetch<T>(
|
|
|
101
106
|
const json = (await res.json()) as T;
|
|
102
107
|
if (!cancelled) {
|
|
103
108
|
setData(json);
|
|
109
|
+
setDataUrl(url);
|
|
104
110
|
dataUrlRef.current = url; // remember the url we now hold data for
|
|
105
111
|
dataAtRef.current = Date.now();
|
|
106
112
|
}
|
|
@@ -149,6 +155,7 @@ export function useSelfFetch<T>(
|
|
|
149
155
|
|
|
150
156
|
return {
|
|
151
157
|
data,
|
|
158
|
+
dataUrl,
|
|
152
159
|
setData,
|
|
153
160
|
isLoading,
|
|
154
161
|
error,
|