@camstack/ui-library 1.2.372 → 1.2.374

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/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.374] - 2026-11-02
11
+
12
+ ### Changed
13
+
14
+ - A select that discovers its options can be an optional picker ('Default' choice, 'missing' label for a gone value); the device export table's Type filter is searchable like Role and Integration.
15
+ - A searchable select no longer throws after it closes on a page that was just left.
16
+
17
+ ## [1.2.373] - 2026-11-02
18
+
19
+ ### Changed
20
+
21
+ - Device export table: selectors on the left with the search on the right, searchable Role and Integration filters showing display names, a bulk bar with select all / none, and a multi export that now lands every selected device
22
+
10
23
  ## [1.2.372] - 2026-11-02
11
24
 
12
25
  ### Changed
@@ -71,6 +71,14 @@ export interface DeviceListGenericProps<T> {
71
71
  /** Label for the type multi-select trigger. Default 'Type'. */
72
72
  readonly filterLabel?: string;
73
73
  /**
74
+ * Render the type filter as the library's searchable multi `OptionSelect`
75
+ * (typing narrows the list), sized like a sibling selector in `filters`,
76
+ * instead of the chips / compact dropdown. Default false.
77
+ */
78
+ readonly searchableTypeFilter?: boolean;
79
+ /** `data-testid` of the searchable type filter. Default `device-list-filter-type`. */
80
+ readonly typeFilterTestId?: string;
81
+ /**
74
82
  * Initial type-filter selection. Empty (default) = no filter. Used once at
75
83
  * mount — later changes to this prop are ignored (local state owns it).
76
84
  */
@@ -94,6 +102,19 @@ export interface DeviceListGenericProps<T> {
94
102
  readonly toolbar?: ReactNode | ((ctx: {
95
103
  readonly filtered: readonly T[];
96
104
  }) => ReactNode);
105
+ /**
106
+ * Extra selectors rendered on the LEFT, before the type filter, so every
107
+ * selector sits in one group and the search box stays alone on the right.
108
+ * Wraps onto their own row on a narrow screen.
109
+ */
110
+ readonly filters?: ReactNode;
111
+ /**
112
+ * Full-width row between the filter row and the table (a bulk-action bar).
113
+ * A function receives the rows the search and filters currently leave.
114
+ */
115
+ readonly belowFilters?: ReactNode | ((ctx: {
116
+ readonly filtered: readonly T[];
117
+ }) => ReactNode);
97
118
  }
98
119
  /**
99
120
  * Generic render-prop list: search + pagination chrome shared with the device
@@ -7,12 +7,25 @@
7
7
  * the only filter, D869).
8
8
  */
9
9
  import type { MultiSelectOption } from "./device-list/multi-select";
10
+ import { type DeviceIntegrationRef } from "./device-list/device-integration";
10
11
  /** The slice of a device row the bulk filters read. */
11
12
  export interface ExportFilterable {
12
13
  readonly id: number;
13
14
  readonly addonId: string;
14
15
  readonly role?: string | null;
16
+ /** The integration that created the device (`integrations.list` `id`). */
17
+ readonly integrationId?: string | null;
15
18
  }
19
+ /** The slice of an `integrations.list` row the provider filter reads. */
20
+ export interface ExportIntegration extends DeviceIntegrationRef {
21
+ readonly name: string;
22
+ }
23
+ /**
24
+ * The provider filter value of a row: the id of the integration it belongs to,
25
+ * or `addon:<addonId>` when no registered integration claims it (or the list
26
+ * has not answered). The raw id is never shown — `providerFilterOptions` names it.
27
+ */
28
+ export declare function providerFilterId(device: ExportFilterable, integrations: readonly ExportIntegration[] | null | undefined): string;
16
29
  export interface ExportBulkFilters {
17
30
  /** Keep rows whose role is one of these; empty = no role filter. */
18
31
  readonly roles: readonly string[];
@@ -24,11 +37,17 @@ export declare const NO_ROLE_FILTER_ID = "(none)";
24
37
  export declare const NO_EXPORT_FILTERS: ExportBulkFilters;
25
38
  export declare function roleFilterId(device: ExportFilterable): string;
26
39
  /** The rows the role / provider filters leave. */
27
- export declare function applyExportFilters<T extends ExportFilterable>(devices: readonly T[], filters: ExportBulkFilters): readonly T[];
28
- /** Distinct roles present among the rows (a roleless row counts as "(none)"). */
29
- export declare function roleFilterOptions(devices: readonly ExportFilterable[]): readonly MultiSelectOption[];
30
- /** Distinct provider addons present among the rows. */
31
- export declare function providerFilterOptions(devices: readonly ExportFilterable[]): readonly MultiSelectOption[];
40
+ export declare function applyExportFilters<T extends ExportFilterable>(devices: readonly T[], filters: ExportBulkFilters, integrations?: readonly ExportIntegration[] | null): readonly T[];
41
+ /**
42
+ * Distinct roles present among the rows, named by `labelOf` (a roleless row is
43
+ * `NO_ROLE_FILTER_ID`).
44
+ */
45
+ export declare function roleFilterOptions(devices: readonly ExportFilterable[], labelOf?: (roleId: string) => string): readonly MultiSelectOption[];
46
+ /**
47
+ * Distinct integrations present among the rows, by NAME. A device no
48
+ * registered integration claims is listed under its addon id.
49
+ */
50
+ export declare function providerFilterOptions(devices: readonly ExportFilterable[], integrations?: readonly ExportIntegration[] | null): readonly MultiSelectOption[];
32
51
  /** `current` plus every shown row. */
33
52
  export declare function selectShown(current: ReadonlySet<string>, shown: readonly ExportFilterable[]): ReadonlySet<string>;
34
53
  /** `current` minus every shown row; selections outside the filter are kept. */
@@ -953,6 +953,8 @@ export declare const useIntegrationInstancesGetInstance: typeof trpc.integration
953
953
  export declare const useIntegrationInstancesCreateInstance: typeof trpc.integrationInstances.createInstance.useMutation;
954
954
  /** Generated alias around `trpc.integrationInstances.updateInstance.useMutation`. */
955
955
  export declare const useIntegrationInstancesUpdateInstance: typeof trpc.integrationInstances.updateInstance.useMutation;
956
+ /** Generated alias around `trpc.integrationInstances.setInstanceEnabled.useMutation`. */
957
+ export declare const useIntegrationInstancesSetInstanceEnabled: typeof trpc.integrationInstances.setInstanceEnabled.useMutation;
956
958
  /** Generated alias around `trpc.integrationInstances.deleteInstance.useMutation`. */
957
959
  export declare const useIntegrationInstancesDeleteInstance: typeof trpc.integrationInstances.deleteInstance.useMutation;
958
960
  /** Generated alias around `trpc.integrationInstances.testInstance.useMutation`. */
package/dist/index.cjs CHANGED
@@ -11668,6 +11668,8 @@ var useIntegrationInstancesGetInstance = /*#__PURE__*/ alias(() => trpc.integrat
11668
11668
  var useIntegrationInstancesCreateInstance = /*#__PURE__*/ alias(() => trpc.integrationInstances.createInstance.useMutation);
11669
11669
  /** Generated alias around `trpc.integrationInstances.updateInstance.useMutation`. */
11670
11670
  var useIntegrationInstancesUpdateInstance = /*#__PURE__*/ alias(() => trpc.integrationInstances.updateInstance.useMutation);
11671
+ /** Generated alias around `trpc.integrationInstances.setInstanceEnabled.useMutation`. */
11672
+ var useIntegrationInstancesSetInstanceEnabled = /*#__PURE__*/ alias(() => trpc.integrationInstances.setInstanceEnabled.useMutation);
11671
11673
  /** Generated alias around `trpc.integrationInstances.deleteInstance.useMutation`. */
11672
11674
  var useIntegrationInstancesDeleteInstance = /*#__PURE__*/ alias(() => trpc.integrationInstances.deleteInstance.useMutation);
11673
11675
  /** Generated alias around `trpc.integrationInstances.testInstance.useMutation`. */
@@ -14365,6 +14367,9 @@ function OptionSelect(props) {
14365
14367
  const triggerRef = (0, react$1.useRef)(null);
14366
14368
  const listboxRef = (0, react$1.useRef)(null);
14367
14369
  const blurTimer = (0, react$1.useRef)(null);
14370
+ (0, react$1.useEffect)(() => () => {
14371
+ if (blurTimer.current) clearTimeout(blurTimer.current);
14372
+ }, []);
14368
14373
  const computeListboxCoords = () => {
14369
14374
  const trigger = triggerRef.current;
14370
14375
  if (trigger === null) return null;
@@ -32221,6 +32226,42 @@ function ConfigDiscoveredSelectControl({ field, values, value, onChange, disable
32221
32226
  const { ready, settled, query, found } = useDiscoveredOptions(field, values);
32222
32227
  const current = value === void 0 || value === null ? "" : String(value);
32223
32228
  const disabledNow = disabled || field.disabled;
32229
+ const noneLabel = field.discoverOptions?.noneLabel;
32230
+ const answered = ready && settled && !query.isLoading && !query.isFetching && !query.isError && query.data !== void 0;
32231
+ if (noneLabel !== void 0) {
32232
+ const known = current === "" || found.some((option) => option.value === current);
32233
+ const missingLabel = field.discoverOptions?.missingLabel;
32234
+ const staleLabel = answered && missingLabel !== void 0 ? `${missingLabel} ${current}` : current;
32235
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32236
+ className: "space-y-1",
32237
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionSelect, {
32238
+ mode: "single",
32239
+ singleDisplay: "field",
32240
+ searchable: true,
32241
+ disabled: disabledNow,
32242
+ value: current,
32243
+ onChange,
32244
+ placeholder: noneLabel,
32245
+ options: [
32246
+ {
32247
+ value: "",
32248
+ label: noneLabel
32249
+ },
32250
+ ...known ? [] : [{
32251
+ value: current,
32252
+ label: staleLabel
32253
+ }],
32254
+ ...found
32255
+ ],
32256
+ testId: controlId,
32257
+ optionTestIdPrefix: `${controlId}-option`
32258
+ }), answered ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
32259
+ "data-testid": `${controlId}-status`,
32260
+ className: `text-[11px] ${query.isError ? "text-danger" : "text-foreground-subtle"}`,
32261
+ children: discoveryStatus(t, ready, settled, query)
32262
+ })]
32263
+ });
32264
+ }
32224
32265
  if (found.length > 0) {
32225
32266
  const known = found.some((option) => option.value === current);
32226
32267
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionSelect, {
@@ -32239,11 +32280,7 @@ function ConfigDiscoveredSelectControl({ field, values, value, onChange, disable
32239
32280
  optionTestIdPrefix: `${controlId}-option`
32240
32281
  });
32241
32282
  }
32242
- let status;
32243
- if (!ready) status = t("uiLibrary.configFormField.discoverNotReady", "Fill in the fields above to load the list, or type the value.");
32244
- else if (!settled || query.isLoading || query.isFetching) status = t("uiLibrary.configFormField.loadingOptions", "Loading options…");
32245
- else if (query.isError) status = t("uiLibrary.configFormField.discoverFailed", "Could not load the list: {{message}}. Type the value instead.", { message: query.error instanceof Error ? query.error.message : String(query.error) });
32246
- else status = t("uiLibrary.configFormField.discoverEmpty", "Nothing was found. Type the value instead.");
32283
+ const status = discoveryStatus(t, ready, settled, query);
32247
32284
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32248
32285
  className: "space-y-1",
32249
32286
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
@@ -32259,6 +32296,13 @@ function ConfigDiscoveredSelectControl({ field, values, value, onChange, disable
32259
32296
  })]
32260
32297
  });
32261
32298
  }
32299
+ /** The one line that says why the list is not (yet) offered. */
32300
+ function discoveryStatus(t, ready, settled, query) {
32301
+ if (!ready) return t("uiLibrary.configFormField.discoverNotReady", "Fill in the fields above to load the list, or type the value.");
32302
+ if (!settled || query.isLoading || query.isFetching) return t("uiLibrary.configFormField.loadingOptions", "Loading options…");
32303
+ if (query.isError) return t("uiLibrary.configFormField.discoverFailed", "Could not load the list: {{message}}. Type the value instead.", { message: query.error instanceof Error ? query.error.message : String(query.error) });
32304
+ return t("uiLibrary.configFormField.discoverEmpty", "Nothing was found. Type the value instead.");
32305
+ }
32262
32306
  //#endregion
32263
32307
  //#region src/composites/config-form-field.tsx
32264
32308
  var INPUT_CLASS = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
@@ -62173,7 +62217,7 @@ var FIXED_PAGE_SIZE = 100;
62173
62217
  */
62174
62218
  function GenericModeList(props) {
62175
62219
  const t = useUiText();
62176
- const { items, getKey, getSearchText, renderItem, getFilterType, mapFilterOption, filterOptions, filterLabel = "Type", initialTypeFilters = [], columns, renderExpanded, isExpanded, rowClassName, onRowClick, isLoading = false, searchPlaceholder = "Search", emptyLabel = "No items found.", pageSize: fixedPageSize = FIXED_PAGE_SIZE, pageSizeSelector, toolbar } = props;
62220
+ const { items, getKey, getSearchText, renderItem, getFilterType, mapFilterOption, filterOptions, filterLabel = "Type", searchableTypeFilter = false, typeFilterTestId = "device-list-filter-type", initialTypeFilters = [], columns, renderExpanded, isExpanded, rowClassName, onRowClick, isLoading = false, searchPlaceholder = "Search", emptyLabel = "No items found.", pageSize: fixedPageSize = FIXED_PAGE_SIZE, pageSizeSelector, toolbar, filters, belowFilters } = props;
62177
62221
  const [chosenPageSize, setChosenPageSize] = usePersistedPageSize(pageSizeSelector ?? {
62178
62222
  storageKey: null,
62179
62223
  options: [fixedPageSize],
@@ -62241,16 +62285,37 @@ function GenericModeList(props) {
62241
62285
  className: "flex flex-col gap-3 h-full min-h-0",
62242
62286
  children: [
62243
62287
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62244
- className: "flex items-center justify-between gap-3 flex-wrap shrink-0",
62245
- children: [getFilterType && typeOptions.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResponsiveTypeFilter, {
62246
- label: filterLabel,
62247
- options: typeOptions,
62248
- selected: typeFilters,
62249
- onChange: setTypeFilters
62250
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62251
- className: "flex items-center gap-2",
62288
+ className: "flex items-start justify-between gap-3 flex-wrap shrink-0",
62289
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62290
+ className: "flex min-w-0 flex-1 flex-wrap items-start gap-2",
62291
+ children: [filters, getFilterType && typeOptions.length > 0 && searchableTypeFilter ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
62292
+ className: "min-w-36 flex-1 sm:w-44 sm:flex-none",
62293
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionSelect, {
62294
+ mode: "multi",
62295
+ searchable: true,
62296
+ options: typeOptions.map((o) => ({
62297
+ value: o.id,
62298
+ label: o.label
62299
+ })),
62300
+ value: typeFilters,
62301
+ onChange: setTypeFilters,
62302
+ placeholder: filterLabel,
62303
+ emptyMessage: t("uiLibrary.deviceExport.filterNoOption", "No match."),
62304
+ testId: typeFilterTestId
62305
+ })
62306
+ }) : getFilterType && typeOptions.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
62307
+ className: "flex min-w-40 flex-1",
62308
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResponsiveTypeFilter, {
62309
+ label: filterLabel,
62310
+ options: typeOptions,
62311
+ selected: typeFilters,
62312
+ onChange: setTypeFilters
62313
+ })
62314
+ }) : null]
62315
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62316
+ className: "flex items-center gap-2 max-sm:w-full",
62252
62317
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62253
- className: "relative",
62318
+ className: "relative max-sm:flex-1",
62254
62319
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-foreground-subtle pointer-events-none" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
62255
62320
  ref: searchRef,
62256
62321
  type: "text",
@@ -62258,11 +62323,12 @@ function GenericModeList(props) {
62258
62323
  onChange: (e) => setSearchInput(e.target.value),
62259
62324
  placeholder: searchPlaceholder,
62260
62325
  "aria-label": searchPlaceholder,
62261
- className: "h-7 pl-7 pr-2.5 rounded-md border border-border bg-surface text-[11px] text-foreground placeholder:text-foreground-subtle/60 focus:outline-none focus:ring-1 focus:ring-primary/40 w-56"
62326
+ className: "h-7 pl-7 pr-2.5 rounded-md border border-border bg-surface text-[11px] text-foreground placeholder:text-foreground-subtle/60 focus:outline-none focus:ring-1 focus:ring-primary/40 w-56 max-sm:w-full"
62262
62327
  })]
62263
62328
  }), typeof toolbar === "function" ? toolbar({ filtered }) : toolbar]
62264
62329
  })]
62265
62330
  }),
62331
+ typeof belowFilters === "function" ? belowFilters({ filtered }) : belowFilters,
62266
62332
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
62267
62333
  className: "flex-1 min-h-0 overflow-y-auto",
62268
62334
  children: isLoading && totalItems === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -62393,6 +62459,17 @@ function DeviceList(props) {
62393
62459
  }
62394
62460
  //#endregion
62395
62461
  //#region src/composites/export-bulk-selection.ts
62462
+ /** The provider filter value of a device no registered integration claims. */
62463
+ var UNREGISTERED_PREFIX = "addon:";
62464
+ /**
62465
+ * The provider filter value of a row: the id of the integration it belongs to,
62466
+ * or `addon:<addonId>` when no registered integration claims it (or the list
62467
+ * has not answered). The raw id is never shown — `providerFilterOptions` names it.
62468
+ */
62469
+ function providerFilterId(device, integrations) {
62470
+ const resolved = deviceIntegrationResolver(integrations)(device);
62471
+ return resolved.kind === "resolved" ? resolved.integration.id : `${UNREGISTERED_PREFIX}${device.addonId}`;
62472
+ }
62396
62473
  /** The role filter value of a row that has no role. */
62397
62474
  var NO_ROLE_FILTER_ID = "(none)";
62398
62475
  var NO_EXPORT_FILTERS = {
@@ -62404,24 +62481,40 @@ function roleFilterId(device) {
62404
62481
  return role === null || role === void 0 || role === "" ? NO_ROLE_FILTER_ID : role;
62405
62482
  }
62406
62483
  /** The rows the role / provider filters leave. */
62407
- function applyExportFilters(devices, filters) {
62484
+ function applyExportFilters(devices, filters, integrations) {
62408
62485
  const roles = new Set(filters.roles);
62409
62486
  const providers = new Set(filters.providers);
62410
- return devices.filter((d) => (roles.size === 0 || roles.has(roleFilterId(d))) && (providers.size === 0 || providers.has(d.addonId)));
62487
+ return devices.filter((d) => (roles.size === 0 || roles.has(roleFilterId(d))) && (providers.size === 0 || providers.has(providerFilterId(d, integrations))));
62411
62488
  }
62412
- function distinctOptions(values) {
62413
- return [...new Set(values)].toSorted().map((id) => ({
62414
- id,
62415
- label: id
62416
- }));
62489
+ function sortedByLabel(options) {
62490
+ return options.toSorted((a, b) => a.label.localeCompare(b.label));
62417
62491
  }
62418
- /** Distinct roles present among the rows (a roleless row counts as "(none)"). */
62419
- function roleFilterOptions(devices) {
62420
- return distinctOptions(devices.map(roleFilterId));
62492
+ /**
62493
+ * Distinct roles present among the rows, named by `labelOf` (a roleless row is
62494
+ * `NO_ROLE_FILTER_ID`).
62495
+ */
62496
+ function roleFilterOptions(devices, labelOf = (id) => id) {
62497
+ return sortedByLabel([...new Set(devices.map(roleFilterId))].map((id) => ({
62498
+ id,
62499
+ label: labelOf(id)
62500
+ })));
62421
62501
  }
62422
- /** Distinct provider addons present among the rows. */
62423
- function providerFilterOptions(devices) {
62424
- return distinctOptions(devices.map((d) => d.addonId));
62502
+ /**
62503
+ * Distinct integrations present among the rows, by NAME. A device no
62504
+ * registered integration claims is listed under its addon id.
62505
+ */
62506
+ function providerFilterOptions(devices, integrations) {
62507
+ const nameById = new Map((integrations ?? []).map((i) => [i.id, i.name]));
62508
+ const options = /* @__PURE__ */ new Map();
62509
+ for (const d of devices) {
62510
+ const id = providerFilterId(d, integrations);
62511
+ if (options.has(id)) continue;
62512
+ options.set(id, nameById.get(id) ?? d.addonId);
62513
+ }
62514
+ return sortedByLabel([...options].map(([id, label]) => ({
62515
+ id,
62516
+ label
62517
+ })));
62425
62518
  }
62426
62519
  /** `current` plus every shown row. */
62427
62520
  function selectShown(current, shown) {
@@ -62577,9 +62670,15 @@ var DeviceItemRowSchema = zod.z.object({
62577
62670
  disabled: zod.z.boolean(),
62578
62671
  parentDeviceId: zod.z.number().nullable(),
62579
62672
  role: zod.z.string().nullable().optional(),
62673
+ integrationId: zod.z.string().nullable().optional(),
62580
62674
  features: zod.z.array(zod.z.string()).optional(),
62581
62675
  isCamera: zod.z.boolean().optional()
62582
62676
  });
62677
+ var IntegrationRowsSchema = zod.z.array(zod.z.object({
62678
+ id: zod.z.string(),
62679
+ addonId: zod.z.string(),
62680
+ name: zod.z.string()
62681
+ }));
62583
62682
  var SupportedKindsSchema = zod.z.array(zod.z.string());
62584
62683
  var STATUS_POLL_INTERVAL_MS = 1e4;
62585
62684
  /** Where the viewer's "rows per page" choice for this table is remembered. */
@@ -62836,6 +62935,11 @@ function DeviceExportPanel({ addonId, onOpenDevice, showSetup = true }) {
62836
62935
  addonId
62837
62936
  }, { retry: false });
62838
62937
  const devicesQuery = useDeviceManagerListAll({ projection: "slim" }, { refetchInterval: FLEET_POLL_INTERVAL_MS });
62938
+ const integrationsQuery = useIntegrationsList(void 0);
62939
+ const integrations = (0, react$1.useMemo)(() => {
62940
+ const parsed = IntegrationRowsSchema.safeParse(integrationsQuery.data);
62941
+ return parsed.success ? parsed.data : null;
62942
+ }, [integrationsQuery.data]);
62839
62943
  const entityKindsQuery = useDeviceExportListSupportedEntityKinds({
62840
62944
  nodeId: "hub",
62841
62945
  addonId
@@ -62953,28 +63057,28 @@ function DeviceExportPanel({ addonId, onOpenDevice, showSetup = true }) {
62953
63057
  const succeeded = [];
62954
63058
  const failed = [];
62955
63059
  const mutation = action === "expose" ? exposeMutation : unexposeMutation;
62956
- (await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
62957
- deviceId,
62958
- nodeId: "hub",
62959
- addonId
62960
- })))).forEach((result, index) => {
62961
- const deviceId = deviceIds[index] ?? "";
63060
+ for (const deviceId of deviceIds) {
62962
63061
  const label = labelFor(deviceId);
62963
- if (result.status === "fulfilled") {
63062
+ try {
63063
+ await mutation.mutateAsync({
63064
+ deviceId,
63065
+ nodeId: "hub",
63066
+ addonId
63067
+ });
62964
63068
  succeeded.push(label);
62965
- return;
63069
+ } catch (reason) {
63070
+ const message = reason instanceof Error ? reason.message : String(reason);
63071
+ console.error(`[device-export] ${action} failed`, {
63072
+ tags: { deviceId: Number.parseInt(deviceId, 10) },
63073
+ addonId,
63074
+ error: message
63075
+ });
63076
+ failed.push({
63077
+ label,
63078
+ message
63079
+ });
62966
63080
  }
62967
- const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
62968
- console.error(`[device-export] ${action} failed`, {
62969
- tags: { deviceId: Number.parseInt(deviceId, 10) },
62970
- addonId,
62971
- error: message
62972
- });
62973
- failed.push({
62974
- label,
62975
- message
62976
- });
62977
- });
63081
+ }
62978
63082
  setOutcome({
62979
63083
  action,
62980
63084
  total: deviceIds.length,
@@ -63035,9 +63139,19 @@ function DeviceExportPanel({ addonId, onOpenDevice, showSetup = true }) {
63035
63139
  running
63036
63140
  ]);
63037
63141
  const [bulkFilters, setBulkFilters] = (0, react$1.useState)(NO_EXPORT_FILTERS);
63038
- const roleOptions = (0, react$1.useMemo)(() => roleFilterOptions(candidateDevices), [candidateDevices]);
63039
- const providerOptions = (0, react$1.useMemo)(() => providerFilterOptions(candidateDevices), [candidateDevices]);
63040
- const shownDevices = (0, react$1.useMemo)(() => applyExportFilters(candidateDevices, bulkFilters), [candidateDevices, bulkFilters]);
63142
+ const roleOptions = (0, react$1.useMemo)(() => roleFilterOptions(candidateDevices, (id) => id === "(none)" ? t("uiLibrary.deviceExport.noRole", "No role") : deviceRoleMetaOf(id, t).label).map((o) => ({
63143
+ value: o.id,
63144
+ label: o.label
63145
+ })), [candidateDevices, t]);
63146
+ const providerOptions = (0, react$1.useMemo)(() => providerFilterOptions(candidateDevices, integrations).map((o) => ({
63147
+ value: o.id,
63148
+ label: o.label
63149
+ })), [candidateDevices, integrations]);
63150
+ const shownDevices = (0, react$1.useMemo)(() => applyExportFilters(candidateDevices, bulkFilters, integrations), [
63151
+ candidateDevices,
63152
+ bulkFilters,
63153
+ integrations
63154
+ ]);
63041
63155
  const shownRows = (0, react$1.useMemo)(() => {
63042
63156
  const deviceRows = shownDevices.map((device) => ({
63043
63157
  kind: "device",
@@ -63150,72 +63264,86 @@ function DeviceExportPanel({ addonId, onOpenDevice, showSetup = true }) {
63150
63264
  toggleSelection,
63151
63265
  t
63152
63266
  ]);
63153
- const renderToolbar = (0, react$1.useCallback)(({ filtered: filteredRows }) => {
63267
+ const filterSelectors = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [roleOptions.length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
63268
+ className: "min-w-36 flex-1 sm:w-44 sm:flex-none",
63269
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionSelect, {
63270
+ mode: "multi",
63271
+ searchable: true,
63272
+ options: roleOptions,
63273
+ value: bulkFilters.roles,
63274
+ onChange: setRoleFilter,
63275
+ placeholder: t("uiLibrary.deviceExport.filterRole", "Role"),
63276
+ emptyMessage: t("uiLibrary.deviceExport.filterNoOption", "No match."),
63277
+ testId: "export-filter-role"
63278
+ })
63279
+ }), providerOptions.length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
63280
+ className: "min-w-36 flex-1 sm:w-44 sm:flex-none",
63281
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionSelect, {
63282
+ mode: "multi",
63283
+ searchable: true,
63284
+ options: providerOptions,
63285
+ value: bulkFilters.providers,
63286
+ onChange: setProviderFilter,
63287
+ placeholder: t("uiLibrary.deviceExport.filterIntegration", "Integration"),
63288
+ emptyMessage: t("uiLibrary.deviceExport.filterNoOption", "No match."),
63289
+ testId: "export-filter-integration"
63290
+ })
63291
+ })] });
63292
+ const renderBulkBar = (0, react$1.useCallback)(({ filtered: filteredRows }) => {
63154
63293
  const filtered = filteredRows.flatMap((row) => row.kind === "device" ? [row.device] : []);
63155
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
63156
- className: "inline-flex flex-wrap items-center gap-1.5",
63294
+ if (filtered.length === 0 && selectedIds.size === 0) return null;
63295
+ const hasSelection = selectedIds.size > 0;
63296
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
63297
+ role: "toolbar",
63298
+ "aria-label": t("uiLibrary.deviceExport.bulkBar", "Bulk export"),
63299
+ className: `flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5 ${hasSelection ? "border-primary/40 bg-primary/10" : "border-border bg-surface"}`,
63157
63300
  children: [
63158
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MultiSelectDropdown, {
63159
- label: t("uiLibrary.deviceExport.filterRole", "Role"),
63160
- options: roleOptions,
63161
- selected: bulkFilters.roles,
63162
- onChange: setRoleFilter
63163
- }),
63164
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MultiSelectDropdown, {
63165
- label: t("uiLibrary.deviceExport.filterProvider", "Provider"),
63166
- options: providerOptions,
63167
- selected: bulkFilters.providers,
63168
- onChange: setProviderFilter
63301
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
63302
+ ...hasSelection ? { "data-testid": "export-selection-count" } : {},
63303
+ className: `text-[11px] font-medium ${hasSelection ? "text-foreground" : "text-foreground-subtle"}`,
63304
+ children: hasSelection ? t("uiLibrary.deviceExport.selected", "{{count}} selected", { count: selectedIds.size }) : t("uiLibrary.deviceExport.noneSelected", "None selected")
63169
63305
  }),
63170
63306
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63171
63307
  size: "sm",
63172
- variant: "secondary",
63308
+ variant: "ghost",
63173
63309
  disabled: filtered.length === 0,
63174
63310
  onClick: () => setSelectedIds((current) => selectShown(current, filtered)),
63175
- children: t("uiLibrary.deviceExport.selectShown", "Select {{count}} shown", { count: filtered.length })
63311
+ children: t("uiLibrary.deviceExport.selectShown", "Select all {{count}} shown", { count: filtered.length })
63176
63312
  }),
63177
- selectedIds.size > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
63313
+ hasSelection && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
63178
63314
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63179
63315
  size: "sm",
63180
63316
  variant: "ghost",
63181
63317
  onClick: () => setSelectedIds((current) => deselectShown(current, filtered)),
63182
63318
  children: t("uiLibrary.deviceExport.deselectShown", "Deselect shown")
63183
63319
  }),
63184
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
63185
- "data-testid": "export-selection-count",
63186
- className: "text-[11px] text-foreground-subtle",
63187
- children: t("uiLibrary.deviceExport.selected", "{{count}} selected", { count: selectedIds.size })
63188
- }),
63189
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63190
- size: "sm",
63191
- variant: "primary",
63192
- disabled: busy,
63193
- onClick: exposeSelected,
63194
- children: t("uiLibrary.deviceExport.exposeN", "Expose {{count}}", { count: selectedIds.size })
63195
- }),
63196
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63197
- size: "sm",
63198
- variant: "secondary",
63199
- disabled: busy,
63200
- onClick: unexposeSelected,
63201
- children: t("uiLibrary.deviceExport.unexposeN", "Unexpose {{count}}", { count: selectedIds.size })
63202
- }),
63203
63320
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63204
63321
  size: "sm",
63205
63322
  variant: "ghost",
63206
63323
  onClick: clearSelection,
63207
63324
  children: t("uiLibrary.deviceExport.clear", "Clear")
63325
+ }),
63326
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
63327
+ className: "ml-auto inline-flex flex-wrap items-center gap-1.5",
63328
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63329
+ size: "sm",
63330
+ variant: "primary",
63331
+ disabled: busy,
63332
+ onClick: exposeSelected,
63333
+ children: t("uiLibrary.deviceExport.exposeN", "Expose {{count}}", { count: selectedIds.size })
63334
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
63335
+ size: "sm",
63336
+ variant: "secondary",
63337
+ disabled: busy,
63338
+ onClick: unexposeSelected,
63339
+ children: t("uiLibrary.deviceExport.unexposeN", "Unexpose {{count}}", { count: selectedIds.size })
63340
+ })]
63208
63341
  })
63209
63342
  ] })
63210
63343
  ]
63211
63344
  });
63212
63345
  }, [
63213
63346
  t,
63214
- roleOptions,
63215
- providerOptions,
63216
- bulkFilters,
63217
- setRoleFilter,
63218
- setProviderFilter,
63219
63347
  selectedIds.size,
63220
63348
  busy,
63221
63349
  exposeSelected,
@@ -63268,9 +63396,13 @@ function DeviceExportPanel({ addonId, onOpenDevice, showSetup = true }) {
63268
63396
  getSearchText,
63269
63397
  getFilterType,
63270
63398
  filterOptions: typeFilterOptions,
63399
+ filterLabel: t("uiLibrary.deviceExport.filterType", "Type"),
63400
+ searchableTypeFilter: true,
63401
+ typeFilterTestId: "export-filter-type",
63271
63402
  columns,
63272
63403
  onRowClick: onOpenDevice ? onRowClick : void 0,
63273
- toolbar: renderToolbar,
63404
+ filters: filterSelectors,
63405
+ belowFilters: renderBulkBar,
63274
63406
  pageSizeSelector: DEVICE_EXPORT_PAGE_SIZE,
63275
63407
  searchPlaceholder: t("uiLibrary.deviceExport.search", "Search by name or stableId"),
63276
63408
  emptyLabel: t("uiLibrary.deviceExport.noMatch", "No device matches this search.")
@@ -72899,7 +73031,7 @@ function TimelineSlider({ markers, bands = [], selectedId = null, step = 5, aria
72899
73031
  }
72900
73032
  //#endregion
72901
73033
  //#region src/ui-library-version.ts
72902
- var uiLibraryVersion = "1.2.372";
73034
+ var uiLibraryVersion = "1.2.374";
72903
73035
  //#endregion
72904
73036
  //#region src/hooks/use-account-unit-preferences.ts
72905
73037
  /**
@@ -75369,6 +75501,7 @@ exports.useIntegrationInstancesDescribe = useIntegrationInstancesDescribe;
75369
75501
  exports.useIntegrationInstancesGetInstance = useIntegrationInstancesGetInstance;
75370
75502
  exports.useIntegrationInstancesInstanceFormSchema = useIntegrationInstancesInstanceFormSchema;
75371
75503
  exports.useIntegrationInstancesListInstances = useIntegrationInstancesListInstances;
75504
+ exports.useIntegrationInstancesSetInstanceEnabled = useIntegrationInstancesSetInstanceEnabled;
75372
75505
  exports.useIntegrationInstancesTestInstance = useIntegrationInstancesTestInstance;
75373
75506
  exports.useIntegrationInstancesUpdateInstance = useIntegrationInstancesUpdateInstance;
75374
75507
  exports.useIntegrationsCreate = useIntegrationsCreate;