@camstack/ui-library 1.2.111 → 1.2.113

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.
@@ -1,5 +1,4 @@
1
1
  import { ReactElement } from 'react';
2
- import { UseDeviceProxyTrpc } from '../hooks/use-device-proxy';
3
2
  export interface DeviceExportPanelProps {
4
3
  /**
5
4
  * Addon id of the export addon whose surface is rendered. Used for
@@ -8,16 +7,18 @@ export interface DeviceExportPanelProps {
8
7
  */
9
8
  readonly addonId: string;
10
9
  /**
11
- * tRPC client passed straight to the shared `<DeviceList>` so its
12
- * per-row capability atoms (snapshot, switch, …) work. Supply
13
- * `system.trpcClient` from `useSystem()` at the mount site.
10
+ * Optional callback to open a device-details page.
11
+ *
12
+ * There is no `trpc` prop. It existed to feed the device-mode list's per-row
13
+ * capability atoms (snapshot, switch, live control); this surface renders
14
+ * type + name + export and asks a device nothing, so the prop was removed
15
+ * rather than left as a parameter every caller still threads and nothing
16
+ * reads.
14
17
  */
15
- readonly trpc: UseDeviceProxyTrpc;
16
- /** Optional callback to open a device-details page. */
17
18
  readonly onOpenDevice?: (deviceId: string) => void;
18
19
  }
19
20
  /**
20
21
  * Generic device-export panel. Addon-agnostic — only the `device-export`
21
22
  * cap drives it.
22
23
  */
23
- export declare function DeviceExportPanel({ addonId, trpc, onOpenDevice, }: DeviceExportPanelProps): ReactElement;
24
+ export declare function DeviceExportPanel({ addonId, onOpenDevice }: DeviceExportPanelProps): ReactElement;
package/dist/index.cjs CHANGED
@@ -43813,12 +43813,33 @@ function DeviceList(props) {
43813
43813
  * - a link-state status badge + exposed-device count from `getStatus`,
43814
43814
  * - an optional "Setup" section (pairing QR + copyable fields) from
43815
43815
  * the cap's `setup` block,
43816
- * - ONE unified devices overview — the shared `<DeviceList>` table
43817
- * restricted to this exporter's `listSupportedDeviceKinds`, with a
43818
- * per-row Expose/Exposed toggle wired through the list's
43819
- * `renderRowAction` extension point. Each row reflects whether the
43820
- * device is exposed to THIS integration and lets the operator
43821
- * toggle it directly from the integration page.
43816
+ * - ONE unified devices overview — the shared `<DeviceList>` in its
43817
+ * GENERIC mode (the same table `/integrations` renders), restricted
43818
+ * to this exporter's `listSupportedDeviceKinds`.
43819
+ *
43820
+ * ── What an entry shows, and what it deliberately does not ───────────
43821
+ *
43822
+ * Three columns plus a selection box: device TYPE, NAME, EXPORT. That is
43823
+ * the whole row.
43824
+ *
43825
+ * It used to be the full device-mode row — Hardware (manufacturer ·
43826
+ * model), Features, and the merged Preview cell carrying the per-type
43827
+ * live control, the battery/charging pills, the snapshot eye, the reboot
43828
+ * quick-action, the PTZ button and the reachability dot. Every one of
43829
+ * those is reachable on `/devices` and on the device's own page; none of
43830
+ * them existed only here, which is the ONLY reason they were removable.
43831
+ * The Expose toggle is the affordance this surface owns, so it stays —
43832
+ * and the row click still opens the device page.
43833
+ *
43834
+ * ── Multi-select, and why every run reports ──────────────────────────
43835
+ *
43836
+ * Selecting rows exposes (or unexposes) them in one go. The toolbar
43837
+ * always names the COUNT the buttons will act on, and every run — bulk
43838
+ * or single-row — ends in a summary that names the devices on both
43839
+ * sides. A generic "export failed" would leave the operator re-running
43840
+ * the whole set to discover which half landed; and the previous
43841
+ * single-row toggle used `mutate` with no `onError`, so a refused
43842
+ * accessory changed nothing on screen at all.
43822
43843
  *
43823
43844
  * This composite is the reusable replacement for the per-addon
43824
43845
  * Module-Federation overview tables that each export addon used to
@@ -43933,31 +43954,84 @@ function SetupSection({ setup }) {
43933
43954
  });
43934
43955
  }
43935
43956
  /**
43936
- * Per-row Expose/Exposed toggle — the `renderRowAction` injected into
43937
- * `<DeviceList>`. Reflects whether the row's device is exposed to THIS
43938
- * exporter and flips it via expose/unexpose, routed by `addonId`.
43957
+ * Per-row Expose/Exposed toggle. Reflects whether the row's device is exposed
43958
+ * to THIS exporter and flips it through the SAME runner the bulk buttons use —
43959
+ * so a single-row refusal is reported by name instead of vanishing, which is
43960
+ * what the previous fire-and-forget `mutate` did.
43961
+ *
43962
+ * The accessible name carries the device name (`Expose Garden cam`): several
43963
+ * of these live in one table, and "Expose" alone names none of them.
43939
43964
  */
43940
- function ExportRowToggle({ addonId, deviceId, exposed, busy, onExpose, onUnexpose }) {
43965
+ function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
43941
43966
  const handleClick = (e) => {
43942
43967
  e.stopPropagation();
43943
43968
  if (busy) return;
43944
- if (exposed) onUnexpose(deviceId);
43945
- else onExpose(deviceId);
43969
+ onToggle(device, exposed ? "unexpose" : "expose");
43946
43970
  };
43947
43971
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
43948
43972
  size: "sm",
43949
43973
  variant: exposed ? "ghost" : "primary",
43950
43974
  disabled: busy,
43951
43975
  onClick: handleClick,
43976
+ "aria-label": `${exposed ? "Unexpose" : "Expose"} ${device.name}`,
43952
43977
  title: exposed ? `Stop exposing to "${addonId}"` : `Expose to "${addonId}"`,
43953
43978
  children: exposed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Unlink2, { className: "mr-1 h-3.5 w-3.5" }), "Exposed"] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Share2, { className: "mr-1 h-3.5 w-3.5" }), "Expose"] })
43954
43979
  });
43955
43980
  }
43956
43981
  /**
43982
+ * The result of the last run, named device by device.
43983
+ *
43984
+ * Rendered for a CLEAN run too. "It probably worked" is not something an
43985
+ * operator should have to infer from silence, and a summary that appears only
43986
+ * on failure trains them to read no news as good news right up until the one
43987
+ * time the render itself was what failed.
43988
+ */
43989
+ function BulkSummary({ outcome, onDismiss }) {
43990
+ const verb = outcome.action === "expose" ? "Exposed" : "Unexposed";
43991
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
43992
+ "data-testid": "export-bulk-summary",
43993
+ className: `flex flex-col gap-1 px-4 py-2 border-b border-border text-[11px] ${outcome.failed.length === 0 ? "text-foreground-subtle" : "text-danger"}`,
43994
+ children: [
43995
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
43996
+ className: "flex items-center justify-between gap-2",
43997
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
43998
+ className: "font-medium",
43999
+ children: [
44000
+ verb,
44001
+ " ",
44002
+ outcome.succeeded.length,
44003
+ " of ",
44004
+ outcome.total,
44005
+ " selected device",
44006
+ outcome.total === 1 ? "" : "s",
44007
+ "."
44008
+ ]
44009
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
44010
+ size: "sm",
44011
+ variant: "ghost",
44012
+ onClick: onDismiss,
44013
+ "aria-label": "Dismiss export result",
44014
+ children: "Dismiss"
44015
+ })]
44016
+ }),
44017
+ outcome.succeeded.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44018
+ className: "text-foreground-subtle",
44019
+ children: ["Succeeded: ", outcome.succeeded.join(", ")]
44020
+ }),
44021
+ outcome.failed.map((failure) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
44022
+ "Failed: ",
44023
+ failure.label,
44024
+ " — ",
44025
+ failure.message
44026
+ ] }, failure.label))
44027
+ ]
44028
+ });
44029
+ }
44030
+ /**
43957
44031
  * Generic device-export panel. Addon-agnostic — only the `device-export`
43958
44032
  * cap drives it.
43959
44033
  */
43960
- function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44034
+ function DeviceExportPanel({ addonId, onOpenDevice }) {
43961
44035
  const queryClient = (0, _tanstack_react_query.useQueryClient)();
43962
44036
  const statusQuery = useDeviceExportGetStatus({
43963
44037
  nodeId: "hub",
@@ -44019,38 +44093,171 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44019
44093
  const loading = statusQuery.isLoading || exposedQuery.isLoading || kindsQuery.isLoading || devicesQuery.isLoading;
44020
44094
  const exposedCount = status?.exposedDeviceCount ?? exposedIds.size;
44021
44095
  const busy = exposeMutation.isPending || unexposeMutation.isPending;
44022
- const onExpose = (0, react$1.useCallback)((deviceId) => {
44023
- exposeMutation.mutate({
44024
- deviceId,
44025
- nodeId: "hub",
44026
- addonId
44096
+ const [selectedIds, setSelectedIds] = (0, react$1.useState)(() => /* @__PURE__ */ new Set());
44097
+ const [outcome, setOutcome] = (0, react$1.useState)(null);
44098
+ const [running, setRunning] = (0, react$1.useState)(false);
44099
+ const toggleSelection = (0, react$1.useCallback)((deviceId) => {
44100
+ setSelectedIds((current) => {
44101
+ const next = new Set(current);
44102
+ if (next.has(deviceId)) next.delete(deviceId);
44103
+ else next.add(deviceId);
44104
+ return next;
44027
44105
  });
44028
- }, [exposeMutation, addonId]);
44029
- const onUnexpose = (0, react$1.useCallback)((deviceId) => {
44030
- unexposeMutation.mutate({
44106
+ }, []);
44107
+ const clearSelection = (0, react$1.useCallback)(() => setSelectedIds(/* @__PURE__ */ new Set()), []);
44108
+ const dismissOutcome = (0, react$1.useCallback)(() => setOutcome(null), []);
44109
+ /** Display label for a device id — its name, or the raw id if it left the fleet. */
44110
+ const labelFor = (0, react$1.useCallback)((deviceId) => candidateDevices.find((d) => String(d.id) === deviceId)?.name ?? `#${deviceId}`, [candidateDevices]);
44111
+ /**
44112
+ * The ONE path an expose/unexpose takes — a single row's toggle is a run of
44113
+ * one. Every device is dispatched, every result is recorded, and a failure is
44114
+ * both logged (with `deviceId`, so it can be asked about per-camera) and
44115
+ * named on screen. Nothing here can drop a device quietly: the loop visits
44116
+ * the whole input and each visit lands in `succeeded` or in `failed`.
44117
+ */
44118
+ const runExport = (0, react$1.useCallback)(async (action, deviceIds) => {
44119
+ if (deviceIds.length === 0 || running) return;
44120
+ setRunning(true);
44121
+ setOutcome(null);
44122
+ const succeeded = [];
44123
+ const failed = [];
44124
+ const mutation = action === "expose" ? exposeMutation : unexposeMutation;
44125
+ (await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
44031
44126
  deviceId,
44032
44127
  nodeId: "hub",
44033
44128
  addonId
44129
+ })))).forEach((result, index) => {
44130
+ const deviceId = deviceIds[index] ?? "";
44131
+ const label = labelFor(deviceId);
44132
+ if (result.status === "fulfilled") {
44133
+ succeeded.push(label);
44134
+ return;
44135
+ }
44136
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
44137
+ console.error(`[device-export] ${action} failed`, {
44138
+ tags: { deviceId: Number.parseInt(deviceId, 10) },
44139
+ addonId,
44140
+ error: message
44141
+ });
44142
+ failed.push({
44143
+ label,
44144
+ message
44145
+ });
44034
44146
  });
44035
- }, [unexposeMutation, addonId]);
44036
- const renderRowAction = (0, react$1.useCallback)((device) => {
44037
- const id = String(device.id);
44038
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExportRowToggle, {
44039
- addonId,
44040
- deviceId: id,
44041
- exposed: exposedIds.has(id),
44042
- busy,
44043
- onExpose,
44044
- onUnexpose
44147
+ setOutcome({
44148
+ action,
44149
+ total: deviceIds.length,
44150
+ succeeded,
44151
+ failed
44045
44152
  });
44153
+ setSelectedIds(/* @__PURE__ */ new Set());
44154
+ setRunning(false);
44046
44155
  }, [
44047
44156
  addonId,
44048
- exposedIds,
44157
+ exposeMutation,
44158
+ unexposeMutation,
44159
+ labelFor,
44160
+ running
44161
+ ]);
44162
+ const selectedList = (0, react$1.useMemo)(() => [...selectedIds], [selectedIds]);
44163
+ const exposeSelected = (0, react$1.useCallback)(() => {
44164
+ runExport("expose", selectedList);
44165
+ }, [runExport, selectedList]);
44166
+ const unexposeSelected = (0, react$1.useCallback)(() => {
44167
+ runExport("unexpose", selectedList);
44168
+ }, [runExport, selectedList]);
44169
+ const onRowToggle = (0, react$1.useCallback)((device, action) => {
44170
+ runExport(action, [String(device.id)]);
44171
+ }, [runExport]);
44172
+ const onRowClick = (0, react$1.useCallback)((device) => onOpenDevice?.(String(device.id)), [onOpenDevice]);
44173
+ const getKey = (0, react$1.useCallback)((device) => String(device.id), []);
44174
+ const getSearchText = (0, react$1.useCallback)((device) => `${device.name} ${device.stableId}`, []);
44175
+ const getFilterType = (0, react$1.useCallback)((device) => device.type, []);
44176
+ const columns = (0, react$1.useMemo)(() => [
44177
+ {
44178
+ key: "select",
44179
+ header: "",
44180
+ headerClassName: "w-8",
44181
+ cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
44182
+ type: "checkbox",
44183
+ checked: selectedIds.has(String(device.id)),
44184
+ onChange: () => toggleSelection(String(device.id)),
44185
+ onClick: (e) => e.stopPropagation(),
44186
+ "aria-label": `Select ${device.name}`
44187
+ })
44188
+ },
44189
+ {
44190
+ key: "type",
44191
+ header: "Type",
44192
+ headerClassName: "w-32",
44193
+ cell: (device) => {
44194
+ const meta = deviceTypeMetaOf(device.type);
44195
+ const TypeIcon = meta.icon;
44196
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44197
+ className: "inline-flex items-center gap-1.5 text-[11px] text-foreground-subtle",
44198
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TypeIcon, { className: "h-3.5 w-3.5 shrink-0" }), meta.label]
44199
+ });
44200
+ }
44201
+ },
44202
+ {
44203
+ key: "name",
44204
+ header: "Name",
44205
+ cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
44206
+ className: "text-xs font-medium text-foreground",
44207
+ children: device.name
44208
+ })
44209
+ },
44210
+ {
44211
+ key: "export",
44212
+ header: "Export",
44213
+ headerClassName: "w-32 text-right",
44214
+ cellClassName: "text-right",
44215
+ cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExportRowToggle, {
44216
+ addonId,
44217
+ device,
44218
+ exposed: exposedIds.has(String(device.id)),
44219
+ busy,
44220
+ onToggle: onRowToggle
44221
+ })
44222
+ }
44223
+ ], [
44224
+ addonId,
44049
44225
  busy,
44050
- onExpose,
44051
- onUnexpose
44226
+ exposedIds,
44227
+ onRowToggle,
44228
+ selectedIds,
44229
+ toggleSelection
44052
44230
  ]);
44053
- const onNavigate = (0, react$1.useCallback)((deviceId) => onOpenDevice?.(String(deviceId)), [onOpenDevice]);
44231
+ const selectionToolbar = selectedIds.size === 0 ? void 0 : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44232
+ className: "inline-flex items-center gap-1.5",
44233
+ children: [
44234
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44235
+ "data-testid": "export-selection-count",
44236
+ className: "text-[11px] text-foreground-subtle",
44237
+ children: [selectedIds.size, " selected"]
44238
+ }),
44239
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
44240
+ size: "sm",
44241
+ variant: "primary",
44242
+ disabled: busy,
44243
+ onClick: exposeSelected,
44244
+ children: ["Expose ", selectedIds.size]
44245
+ }),
44246
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
44247
+ size: "sm",
44248
+ variant: "secondary",
44249
+ disabled: busy,
44250
+ onClick: unexposeSelected,
44251
+ children: ["Unexpose ", selectedIds.size]
44252
+ }),
44253
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
44254
+ size: "sm",
44255
+ variant: "ghost",
44256
+ onClick: clearSelection,
44257
+ children: "Clear"
44258
+ })
44259
+ ]
44260
+ });
44054
44261
  const hasSupportedKinds = supportedKindList.length > 0;
44055
44262
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
44056
44263
  className: "rounded-lg border border-border bg-surface overflow-hidden",
@@ -44081,19 +44288,27 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44081
44288
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, { message: status.error })
44082
44289
  }),
44083
44290
  status?.setup && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SetupSection, { setup: status.setup }),
44291
+ outcome !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkSummary, {
44292
+ outcome,
44293
+ onDismiss: dismissOutcome
44294
+ }),
44084
44295
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
44085
44296
  className: "p-3",
44086
44297
  children: loading && candidateDevices.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
44087
44298
  className: "flex items-center gap-2 px-1 py-6 text-xs text-foreground-subtle",
44088
44299
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading exportable devices…"]
44089
44300
  }) : hasSupportedKinds && candidateDevices.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceList, {
44090
- devices: candidateDevices,
44091
- trpc,
44092
- filters: { typeOptions: supportedTypeOptions },
44093
- urlScope: "nested",
44094
- lsKey: `device-export:${addonId}`,
44095
- onNavigate: onOpenDevice ? onNavigate : void 0,
44096
- renderRowAction
44301
+ mode: "generic",
44302
+ items: candidateDevices,
44303
+ getKey,
44304
+ getSearchText,
44305
+ getFilterType,
44306
+ filterOptions: supportedTypeOptions,
44307
+ columns,
44308
+ onRowClick: onOpenDevice ? onRowClick : void 0,
44309
+ toolbar: selectionToolbar,
44310
+ searchPlaceholder: "Search by name or stableId",
44311
+ emptyLabel: "No device matches this search."
44097
44312
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
44098
44313
  icon: Share2,
44099
44314
  title: "No exportable devices",
package/dist/index.js CHANGED
@@ -43789,12 +43789,33 @@ function DeviceList(props) {
43789
43789
  * - a link-state status badge + exposed-device count from `getStatus`,
43790
43790
  * - an optional "Setup" section (pairing QR + copyable fields) from
43791
43791
  * the cap's `setup` block,
43792
- * - ONE unified devices overview — the shared `<DeviceList>` table
43793
- * restricted to this exporter's `listSupportedDeviceKinds`, with a
43794
- * per-row Expose/Exposed toggle wired through the list's
43795
- * `renderRowAction` extension point. Each row reflects whether the
43796
- * device is exposed to THIS integration and lets the operator
43797
- * toggle it directly from the integration page.
43792
+ * - ONE unified devices overview — the shared `<DeviceList>` in its
43793
+ * GENERIC mode (the same table `/integrations` renders), restricted
43794
+ * to this exporter's `listSupportedDeviceKinds`.
43795
+ *
43796
+ * ── What an entry shows, and what it deliberately does not ───────────
43797
+ *
43798
+ * Three columns plus a selection box: device TYPE, NAME, EXPORT. That is
43799
+ * the whole row.
43800
+ *
43801
+ * It used to be the full device-mode row — Hardware (manufacturer ·
43802
+ * model), Features, and the merged Preview cell carrying the per-type
43803
+ * live control, the battery/charging pills, the snapshot eye, the reboot
43804
+ * quick-action, the PTZ button and the reachability dot. Every one of
43805
+ * those is reachable on `/devices` and on the device's own page; none of
43806
+ * them existed only here, which is the ONLY reason they were removable.
43807
+ * The Expose toggle is the affordance this surface owns, so it stays —
43808
+ * and the row click still opens the device page.
43809
+ *
43810
+ * ── Multi-select, and why every run reports ──────────────────────────
43811
+ *
43812
+ * Selecting rows exposes (or unexposes) them in one go. The toolbar
43813
+ * always names the COUNT the buttons will act on, and every run — bulk
43814
+ * or single-row — ends in a summary that names the devices on both
43815
+ * sides. A generic "export failed" would leave the operator re-running
43816
+ * the whole set to discover which half landed; and the previous
43817
+ * single-row toggle used `mutate` with no `onError`, so a refused
43818
+ * accessory changed nothing on screen at all.
43798
43819
  *
43799
43820
  * This composite is the reusable replacement for the per-addon
43800
43821
  * Module-Federation overview tables that each export addon used to
@@ -43909,31 +43930,84 @@ function SetupSection({ setup }) {
43909
43930
  });
43910
43931
  }
43911
43932
  /**
43912
- * Per-row Expose/Exposed toggle — the `renderRowAction` injected into
43913
- * `<DeviceList>`. Reflects whether the row's device is exposed to THIS
43914
- * exporter and flips it via expose/unexpose, routed by `addonId`.
43933
+ * Per-row Expose/Exposed toggle. Reflects whether the row's device is exposed
43934
+ * to THIS exporter and flips it through the SAME runner the bulk buttons use —
43935
+ * so a single-row refusal is reported by name instead of vanishing, which is
43936
+ * what the previous fire-and-forget `mutate` did.
43937
+ *
43938
+ * The accessible name carries the device name (`Expose Garden cam`): several
43939
+ * of these live in one table, and "Expose" alone names none of them.
43915
43940
  */
43916
- function ExportRowToggle({ addonId, deviceId, exposed, busy, onExpose, onUnexpose }) {
43941
+ function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
43917
43942
  const handleClick = (e) => {
43918
43943
  e.stopPropagation();
43919
43944
  if (busy) return;
43920
- if (exposed) onUnexpose(deviceId);
43921
- else onExpose(deviceId);
43945
+ onToggle(device, exposed ? "unexpose" : "expose");
43922
43946
  };
43923
43947
  return /* @__PURE__ */ jsx(Button, {
43924
43948
  size: "sm",
43925
43949
  variant: exposed ? "ghost" : "primary",
43926
43950
  disabled: busy,
43927
43951
  onClick: handleClick,
43952
+ "aria-label": `${exposed ? "Unexpose" : "Expose"} ${device.name}`,
43928
43953
  title: exposed ? `Stop exposing to "${addonId}"` : `Expose to "${addonId}"`,
43929
43954
  children: exposed ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Unlink2, { className: "mr-1 h-3.5 w-3.5" }), "Exposed"] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Share2, { className: "mr-1 h-3.5 w-3.5" }), "Expose"] })
43930
43955
  });
43931
43956
  }
43932
43957
  /**
43958
+ * The result of the last run, named device by device.
43959
+ *
43960
+ * Rendered for a CLEAN run too. "It probably worked" is not something an
43961
+ * operator should have to infer from silence, and a summary that appears only
43962
+ * on failure trains them to read no news as good news right up until the one
43963
+ * time the render itself was what failed.
43964
+ */
43965
+ function BulkSummary({ outcome, onDismiss }) {
43966
+ const verb = outcome.action === "expose" ? "Exposed" : "Unexposed";
43967
+ return /* @__PURE__ */ jsxs("div", {
43968
+ "data-testid": "export-bulk-summary",
43969
+ className: `flex flex-col gap-1 px-4 py-2 border-b border-border text-[11px] ${outcome.failed.length === 0 ? "text-foreground-subtle" : "text-danger"}`,
43970
+ children: [
43971
+ /* @__PURE__ */ jsxs("div", {
43972
+ className: "flex items-center justify-between gap-2",
43973
+ children: [/* @__PURE__ */ jsxs("span", {
43974
+ className: "font-medium",
43975
+ children: [
43976
+ verb,
43977
+ " ",
43978
+ outcome.succeeded.length,
43979
+ " of ",
43980
+ outcome.total,
43981
+ " selected device",
43982
+ outcome.total === 1 ? "" : "s",
43983
+ "."
43984
+ ]
43985
+ }), /* @__PURE__ */ jsx(Button, {
43986
+ size: "sm",
43987
+ variant: "ghost",
43988
+ onClick: onDismiss,
43989
+ "aria-label": "Dismiss export result",
43990
+ children: "Dismiss"
43991
+ })]
43992
+ }),
43993
+ outcome.succeeded.length > 0 && /* @__PURE__ */ jsxs("span", {
43994
+ className: "text-foreground-subtle",
43995
+ children: ["Succeeded: ", outcome.succeeded.join(", ")]
43996
+ }),
43997
+ outcome.failed.map((failure) => /* @__PURE__ */ jsxs("span", { children: [
43998
+ "Failed: ",
43999
+ failure.label,
44000
+ " — ",
44001
+ failure.message
44002
+ ] }, failure.label))
44003
+ ]
44004
+ });
44005
+ }
44006
+ /**
43933
44007
  * Generic device-export panel. Addon-agnostic — only the `device-export`
43934
44008
  * cap drives it.
43935
44009
  */
43936
- function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44010
+ function DeviceExportPanel({ addonId, onOpenDevice }) {
43937
44011
  const queryClient = useQueryClient();
43938
44012
  const statusQuery = useDeviceExportGetStatus({
43939
44013
  nodeId: "hub",
@@ -43995,38 +44069,171 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
43995
44069
  const loading = statusQuery.isLoading || exposedQuery.isLoading || kindsQuery.isLoading || devicesQuery.isLoading;
43996
44070
  const exposedCount = status?.exposedDeviceCount ?? exposedIds.size;
43997
44071
  const busy = exposeMutation.isPending || unexposeMutation.isPending;
43998
- const onExpose = useCallback((deviceId) => {
43999
- exposeMutation.mutate({
44000
- deviceId,
44001
- nodeId: "hub",
44002
- addonId
44072
+ const [selectedIds, setSelectedIds] = useState(() => /* @__PURE__ */ new Set());
44073
+ const [outcome, setOutcome] = useState(null);
44074
+ const [running, setRunning] = useState(false);
44075
+ const toggleSelection = useCallback((deviceId) => {
44076
+ setSelectedIds((current) => {
44077
+ const next = new Set(current);
44078
+ if (next.has(deviceId)) next.delete(deviceId);
44079
+ else next.add(deviceId);
44080
+ return next;
44003
44081
  });
44004
- }, [exposeMutation, addonId]);
44005
- const onUnexpose = useCallback((deviceId) => {
44006
- unexposeMutation.mutate({
44082
+ }, []);
44083
+ const clearSelection = useCallback(() => setSelectedIds(/* @__PURE__ */ new Set()), []);
44084
+ const dismissOutcome = useCallback(() => setOutcome(null), []);
44085
+ /** Display label for a device id — its name, or the raw id if it left the fleet. */
44086
+ const labelFor = useCallback((deviceId) => candidateDevices.find((d) => String(d.id) === deviceId)?.name ?? `#${deviceId}`, [candidateDevices]);
44087
+ /**
44088
+ * The ONE path an expose/unexpose takes — a single row's toggle is a run of
44089
+ * one. Every device is dispatched, every result is recorded, and a failure is
44090
+ * both logged (with `deviceId`, so it can be asked about per-camera) and
44091
+ * named on screen. Nothing here can drop a device quietly: the loop visits
44092
+ * the whole input and each visit lands in `succeeded` or in `failed`.
44093
+ */
44094
+ const runExport = useCallback(async (action, deviceIds) => {
44095
+ if (deviceIds.length === 0 || running) return;
44096
+ setRunning(true);
44097
+ setOutcome(null);
44098
+ const succeeded = [];
44099
+ const failed = [];
44100
+ const mutation = action === "expose" ? exposeMutation : unexposeMutation;
44101
+ (await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
44007
44102
  deviceId,
44008
44103
  nodeId: "hub",
44009
44104
  addonId
44105
+ })))).forEach((result, index) => {
44106
+ const deviceId = deviceIds[index] ?? "";
44107
+ const label = labelFor(deviceId);
44108
+ if (result.status === "fulfilled") {
44109
+ succeeded.push(label);
44110
+ return;
44111
+ }
44112
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
44113
+ console.error(`[device-export] ${action} failed`, {
44114
+ tags: { deviceId: Number.parseInt(deviceId, 10) },
44115
+ addonId,
44116
+ error: message
44117
+ });
44118
+ failed.push({
44119
+ label,
44120
+ message
44121
+ });
44010
44122
  });
44011
- }, [unexposeMutation, addonId]);
44012
- const renderRowAction = useCallback((device) => {
44013
- const id = String(device.id);
44014
- return /* @__PURE__ */ jsx(ExportRowToggle, {
44015
- addonId,
44016
- deviceId: id,
44017
- exposed: exposedIds.has(id),
44018
- busy,
44019
- onExpose,
44020
- onUnexpose
44123
+ setOutcome({
44124
+ action,
44125
+ total: deviceIds.length,
44126
+ succeeded,
44127
+ failed
44021
44128
  });
44129
+ setSelectedIds(/* @__PURE__ */ new Set());
44130
+ setRunning(false);
44022
44131
  }, [
44023
44132
  addonId,
44024
- exposedIds,
44133
+ exposeMutation,
44134
+ unexposeMutation,
44135
+ labelFor,
44136
+ running
44137
+ ]);
44138
+ const selectedList = useMemo(() => [...selectedIds], [selectedIds]);
44139
+ const exposeSelected = useCallback(() => {
44140
+ runExport("expose", selectedList);
44141
+ }, [runExport, selectedList]);
44142
+ const unexposeSelected = useCallback(() => {
44143
+ runExport("unexpose", selectedList);
44144
+ }, [runExport, selectedList]);
44145
+ const onRowToggle = useCallback((device, action) => {
44146
+ runExport(action, [String(device.id)]);
44147
+ }, [runExport]);
44148
+ const onRowClick = useCallback((device) => onOpenDevice?.(String(device.id)), [onOpenDevice]);
44149
+ const getKey = useCallback((device) => String(device.id), []);
44150
+ const getSearchText = useCallback((device) => `${device.name} ${device.stableId}`, []);
44151
+ const getFilterType = useCallback((device) => device.type, []);
44152
+ const columns = useMemo(() => [
44153
+ {
44154
+ key: "select",
44155
+ header: "",
44156
+ headerClassName: "w-8",
44157
+ cell: (device) => /* @__PURE__ */ jsx("input", {
44158
+ type: "checkbox",
44159
+ checked: selectedIds.has(String(device.id)),
44160
+ onChange: () => toggleSelection(String(device.id)),
44161
+ onClick: (e) => e.stopPropagation(),
44162
+ "aria-label": `Select ${device.name}`
44163
+ })
44164
+ },
44165
+ {
44166
+ key: "type",
44167
+ header: "Type",
44168
+ headerClassName: "w-32",
44169
+ cell: (device) => {
44170
+ const meta = deviceTypeMetaOf(device.type);
44171
+ const TypeIcon = meta.icon;
44172
+ return /* @__PURE__ */ jsxs("span", {
44173
+ className: "inline-flex items-center gap-1.5 text-[11px] text-foreground-subtle",
44174
+ children: [/* @__PURE__ */ jsx(TypeIcon, { className: "h-3.5 w-3.5 shrink-0" }), meta.label]
44175
+ });
44176
+ }
44177
+ },
44178
+ {
44179
+ key: "name",
44180
+ header: "Name",
44181
+ cell: (device) => /* @__PURE__ */ jsx("span", {
44182
+ className: "text-xs font-medium text-foreground",
44183
+ children: device.name
44184
+ })
44185
+ },
44186
+ {
44187
+ key: "export",
44188
+ header: "Export",
44189
+ headerClassName: "w-32 text-right",
44190
+ cellClassName: "text-right",
44191
+ cell: (device) => /* @__PURE__ */ jsx(ExportRowToggle, {
44192
+ addonId,
44193
+ device,
44194
+ exposed: exposedIds.has(String(device.id)),
44195
+ busy,
44196
+ onToggle: onRowToggle
44197
+ })
44198
+ }
44199
+ ], [
44200
+ addonId,
44025
44201
  busy,
44026
- onExpose,
44027
- onUnexpose
44202
+ exposedIds,
44203
+ onRowToggle,
44204
+ selectedIds,
44205
+ toggleSelection
44028
44206
  ]);
44029
- const onNavigate = useCallback((deviceId) => onOpenDevice?.(String(deviceId)), [onOpenDevice]);
44207
+ const selectionToolbar = selectedIds.size === 0 ? void 0 : /* @__PURE__ */ jsxs("span", {
44208
+ className: "inline-flex items-center gap-1.5",
44209
+ children: [
44210
+ /* @__PURE__ */ jsxs("span", {
44211
+ "data-testid": "export-selection-count",
44212
+ className: "text-[11px] text-foreground-subtle",
44213
+ children: [selectedIds.size, " selected"]
44214
+ }),
44215
+ /* @__PURE__ */ jsxs(Button, {
44216
+ size: "sm",
44217
+ variant: "primary",
44218
+ disabled: busy,
44219
+ onClick: exposeSelected,
44220
+ children: ["Expose ", selectedIds.size]
44221
+ }),
44222
+ /* @__PURE__ */ jsxs(Button, {
44223
+ size: "sm",
44224
+ variant: "secondary",
44225
+ disabled: busy,
44226
+ onClick: unexposeSelected,
44227
+ children: ["Unexpose ", selectedIds.size]
44228
+ }),
44229
+ /* @__PURE__ */ jsx(Button, {
44230
+ size: "sm",
44231
+ variant: "ghost",
44232
+ onClick: clearSelection,
44233
+ children: "Clear"
44234
+ })
44235
+ ]
44236
+ });
44030
44237
  const hasSupportedKinds = supportedKindList.length > 0;
44031
44238
  return /* @__PURE__ */ jsxs("div", {
44032
44239
  className: "rounded-lg border border-border bg-surface overflow-hidden",
@@ -44057,19 +44264,27 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44057
44264
  children: /* @__PURE__ */ jsx(ErrorBox, { message: status.error })
44058
44265
  }),
44059
44266
  status?.setup && /* @__PURE__ */ jsx(SetupSection, { setup: status.setup }),
44267
+ outcome !== null && /* @__PURE__ */ jsx(BulkSummary, {
44268
+ outcome,
44269
+ onDismiss: dismissOutcome
44270
+ }),
44060
44271
  /* @__PURE__ */ jsx("div", {
44061
44272
  className: "p-3",
44062
44273
  children: loading && candidateDevices.length === 0 ? /* @__PURE__ */ jsxs("div", {
44063
44274
  className: "flex items-center gap-2 px-1 py-6 text-xs text-foreground-subtle",
44064
44275
  children: [/* @__PURE__ */ jsx(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading exportable devices…"]
44065
44276
  }) : hasSupportedKinds && candidateDevices.length > 0 ? /* @__PURE__ */ jsx(DeviceList, {
44066
- devices: candidateDevices,
44067
- trpc,
44068
- filters: { typeOptions: supportedTypeOptions },
44069
- urlScope: "nested",
44070
- lsKey: `device-export:${addonId}`,
44071
- onNavigate: onOpenDevice ? onNavigate : void 0,
44072
- renderRowAction
44277
+ mode: "generic",
44278
+ items: candidateDevices,
44279
+ getKey,
44280
+ getSearchText,
44281
+ getFilterType,
44282
+ filterOptions: supportedTypeOptions,
44283
+ columns,
44284
+ onRowClick: onOpenDevice ? onRowClick : void 0,
44285
+ toolbar: selectionToolbar,
44286
+ searchPlaceholder: "Search by name or stableId",
44287
+ emptyLabel: "No device matches this search."
44073
44288
  }) : /* @__PURE__ */ jsx(EmptyState, {
44074
44289
  icon: Share2,
44075
44290
  title: "No exportable devices",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.111",
3
+ "version": "1.2.113",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",