@flamingo-stack/openframe-frontend-core 0.0.852 → 0.0.854

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.
Files changed (163) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-G7GF4PWA.cjs → chunk-2IJMHBOK.cjs} +3 -3
  3. package/dist/{chunk-IC4IJJB2.cjs → chunk-3WXHPD6N.cjs} +55 -55
  4. package/dist/{chunk-UV45EPXN.cjs → chunk-4LTLEUY5.cjs} +5 -5
  5. package/dist/{chunk-BNHTBLAG.cjs → chunk-4O5N5CGK.cjs} +17 -17
  6. package/dist/{chunk-COWMXUHN.cjs → chunk-4SSJ3T3N.cjs} +3 -1
  7. package/dist/{chunk-IUDBG3HJ.cjs → chunk-5HMENXSR.cjs} +3 -3
  8. package/dist/{chunk-COJG2ATY.cjs → chunk-5OENPMRU.cjs} +43 -43
  9. package/dist/{chunk-IO6NN3SL.cjs → chunk-5TXE7BXT.cjs} +46 -46
  10. package/dist/{chunk-EIACEY2L.cjs → chunk-63ZV3UGG.cjs} +10 -10
  11. package/dist/{chunk-HTYJUMFB.cjs → chunk-6DCUGIGU.cjs} +2 -2
  12. package/dist/{chunk-GEZ3UXCA.cjs → chunk-6XU3RCK3.cjs} +18 -18
  13. package/dist/{chunk-3ZQESOPJ.cjs → chunk-7J5NSWJG.cjs} +87 -87
  14. package/dist/{chunk-MXY2OGEV.cjs → chunk-7MOBD44H.cjs} +28 -28
  15. package/dist/{chunk-U767P4JU.cjs → chunk-ACPOJE32.cjs} +9 -9
  16. package/dist/{chunk-CHGIACIP.cjs → chunk-BAA3YCLT.cjs} +72 -72
  17. package/dist/{chunk-VUHHLRAY.cjs → chunk-D2XGRWPW.cjs} +73 -73
  18. package/dist/{chunk-5RMBLOJ5.cjs → chunk-EIHMPQRQ.cjs} +9 -9
  19. package/dist/{chunk-3SJVF3HW.cjs → chunk-ETQNMR2K.cjs} +5 -5
  20. package/dist/{chunk-2W22LY3W.cjs → chunk-FBANHIAG.cjs} +9 -9
  21. package/dist/{chunk-P63CRPWI.cjs → chunk-FJXSKMQJ.cjs} +20 -20
  22. package/dist/{chunk-QOW2GP3V.cjs → chunk-GVHCQRUE.cjs} +21 -21
  23. package/dist/{chunk-KNN2GCMW.cjs → chunk-H3GBPYPS.cjs} +18 -18
  24. package/dist/{chunk-FW7RKWPY.cjs → chunk-JKBNK5U6.cjs} +3 -3
  25. package/dist/{chunk-W7OFAROY.cjs → chunk-KA3L6D5N.cjs} +3 -3
  26. package/dist/{chunk-USPUZO53.cjs → chunk-LQDAB5V5.cjs} +4 -4
  27. package/dist/{chunk-KHOB3TES.cjs → chunk-ONYANAMA.cjs} +4 -4
  28. package/dist/{chunk-D3H6AI7O.cjs → chunk-PRJZMZ3X.cjs} +1182 -746
  29. package/dist/chunk-QD727QFI.cjs +62 -0
  30. package/dist/{chunk-X3WIOMEE.cjs → chunk-RMI6O3AE.cjs} +32 -32
  31. package/dist/{chunk-MFDRCTM3.cjs → chunk-S5P6CF5R.cjs} +47 -47
  32. package/dist/{chunk-477P6AQH.cjs → chunk-TMPLHUUU.cjs} +6 -6
  33. package/dist/{chunk-UO2EJGRJ.cjs → chunk-UPQ4MAR7.cjs} +14 -14
  34. package/dist/{chunk-LFEURIZN.cjs → chunk-VMAOZA2V.cjs} +215 -215
  35. package/dist/{chunk-NMWLXHFH.cjs → chunk-YY3WP2YQ.cjs} +30 -30
  36. package/dist/{chunk-WFAZMASF.cjs → chunk-ZDPPNHA2.cjs} +3 -3
  37. package/dist/{cloud-tenants-O33HCUYB.cjs → cloud-tenants-5RNK4ZAT.cjs} +19 -19
  38. package/dist/components/case-studies/index.cjs +33 -33
  39. package/dist/components/chat/index.cjs +13 -13
  40. package/dist/components/contact/index.cjs +20 -20
  41. package/dist/components/docs/index.cjs +23 -23
  42. package/dist/components/embeds/index.cjs +20 -20
  43. package/dist/components/faq/index.cjs +21 -21
  44. package/dist/components/features/cloud-tenants/index.d.ts +7 -5
  45. package/dist/components/features/cloud-tenants/index.d.ts.map +1 -1
  46. package/dist/components/features/cloud-tenants/index.js +8 -1
  47. package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.d.ts +26 -0
  48. package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.d.ts.map +1 -0
  49. package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.js +176 -0
  50. package/dist/components/features/cloud-tenants/tenant-access-cell.d.ts +4 -2
  51. package/dist/components/features/cloud-tenants/tenant-access-cell.d.ts.map +1 -1
  52. package/dist/components/features/cloud-tenants/tenant-access-cell.js +3 -2
  53. package/dist/components/features/cloud-tenants/tenant-customer-cell.d.ts +12 -5
  54. package/dist/components/features/cloud-tenants/tenant-customer-cell.d.ts.map +1 -1
  55. package/dist/components/features/cloud-tenants/tenant-customer-cell.js +38 -7
  56. package/dist/components/features/cloud-tenants/tenant-licenses-cell.d.ts +7 -0
  57. package/dist/components/features/cloud-tenants/tenant-licenses-cell.d.ts.map +1 -0
  58. package/dist/components/features/cloud-tenants/tenant-licenses-cell.js +17 -0
  59. package/dist/components/features/cloud-tenants/tenant-presentation.d.ts +19 -0
  60. package/dist/components/features/cloud-tenants/tenant-presentation.d.ts.map +1 -1
  61. package/dist/components/features/cloud-tenants/tenant-presentation.js +24 -3
  62. package/dist/components/features/cloud-tenants/tenant-row.d.ts +35 -8
  63. package/dist/components/features/cloud-tenants/tenant-row.d.ts.map +1 -1
  64. package/dist/components/features/cloud-tenants/tenants-table-columns.d.ts +9 -3
  65. package/dist/components/features/cloud-tenants/tenants-table-columns.d.ts.map +1 -1
  66. package/dist/components/features/cloud-tenants/tenants-table-columns.js +5 -3
  67. package/dist/components/features/cloud-tenants/tenants-table-view.d.ts +27 -6
  68. package/dist/components/features/cloud-tenants/tenants-table-view.d.ts.map +1 -1
  69. package/dist/components/features/cloud-tenants/tenants-table-view.js +75 -17
  70. package/dist/components/features/devices/device-directory.d.ts +18 -0
  71. package/dist/components/features/devices/device-directory.d.ts.map +1 -0
  72. package/dist/components/features/devices/device-directory.js +35 -0
  73. package/dist/components/features/devices/devices-filter-toolbar.d.ts +3 -1
  74. package/dist/components/features/devices/devices-filter-toolbar.d.ts.map +1 -1
  75. package/dist/components/features/devices/devices-filter-toolbar.js +2 -1
  76. package/dist/components/features/devices/devices-panel-view.d.ts +3 -1
  77. package/dist/components/features/devices/devices-panel-view.d.ts.map +1 -1
  78. package/dist/components/features/devices/devices-panel-view.js +11 -2
  79. package/dist/components/features/devices/devices-table-columns.d.ts +17 -3
  80. package/dist/components/features/devices/devices-table-columns.d.ts.map +1 -1
  81. package/dist/components/features/devices/devices-table-columns.js +80 -10
  82. package/dist/components/features/devices/index.d.ts +1 -0
  83. package/dist/components/features/devices/index.d.ts.map +1 -1
  84. package/dist/components/features/devices/index.js +8 -0
  85. package/dist/components/features/devices/types.d.ts +14 -0
  86. package/dist/components/features/devices/types.d.ts.map +1 -1
  87. package/dist/components/features/devices/use-tag-filter-modal.d.ts +1 -0
  88. package/dist/components/features/devices/use-tag-filter-modal.d.ts.map +1 -1
  89. package/dist/components/features/devices/use-tag-filter-modal.js +13 -8
  90. package/dist/components/features/index.cjs +33 -19
  91. package/dist/components/help-center-pages/index.cjs +120 -120
  92. package/dist/components/help-center-pages/trust-center-page.d.ts +1 -1
  93. package/dist/components/index.cjs +96 -76
  94. package/dist/components/meeting-scheduler/index.cjs +82 -82
  95. package/dist/components/navigation/app-layout.js +2 -2
  96. package/dist/components/navigation/index.cjs +22 -22
  97. package/dist/components/onboarding-guides/index.cjs +22 -22
  98. package/dist/components/platform/tool-badge-mark.d.ts +1 -1
  99. package/dist/components/product-demo/index.cjs +30 -30
  100. package/dist/components/related-content/index.cjs +8 -8
  101. package/dist/components/tickets/index.cjs +24 -24
  102. package/dist/components/ui/data-attribution.d.ts +8 -3
  103. package/dist/components/ui/data-attribution.d.ts.map +1 -1
  104. package/dist/components/ui/data-attribution.js +17 -6
  105. package/dist/components/ui/date-picker.d.ts +2 -0
  106. package/dist/components/ui/date-picker.d.ts.map +1 -1
  107. package/dist/components/ui/date-picker.js +19 -6
  108. package/dist/components/ui/index.cjs +25 -19
  109. package/dist/components/ui/index.d.ts +1 -0
  110. package/dist/components/ui/index.d.ts.map +1 -1
  111. package/dist/components/ui/index.js +1 -0
  112. package/dist/{devices-2RGGMC2B.cjs → devices-44O5PZOW.cjs} +20 -20
  113. package/dist/{embeddable-chat-SJEEJI4N.cjs → embeddable-chat-W3CLMGC3.cjs} +90 -90
  114. package/dist/hooks/index.cjs +11 -7
  115. package/dist/hooks/ui/use-content-breakpoint.d.ts +8 -0
  116. package/dist/hooks/ui/use-content-breakpoint.d.ts.map +1 -1
  117. package/dist/hooks/ui/use-content-breakpoint.js +17 -3
  118. package/dist/hooks/ui/use-media-query.d.ts +1 -0
  119. package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
  120. package/dist/hooks/ui/use-media-query.js +3 -1
  121. package/dist/index.cjs +44 -24
  122. package/dist/{knowledge-5TJIXVMD.cjs → knowledge-UUU3WVO3.cjs} +20 -20
  123. package/dist/{logs-HGQOTRKZ.cjs → logs-DFHNSODA.cjs} +26 -26
  124. package/dist/{policies-GWFSLVSM.cjs → policies-RGFUGDBP.cjs} +23 -23
  125. package/dist/{remote-session-SG44CNOK.cjs → remote-session-U3H77HKR.cjs} +10 -10
  126. package/dist/{scripts-PGJNAZF4.cjs → scripts-KOVO5DHW.cjs} +26 -26
  127. package/dist/{software-update-KKLSYHDI.cjs → software-update-4EKHVV5W.cjs} +23 -23
  128. package/dist/{tickets-board-SQY2Y6QV.cjs → tickets-board-YIFOVMDX.cjs} +10 -10
  129. package/dist/types/data-sync.d.ts +4 -1
  130. package/dist/types/data-sync.d.ts.map +1 -1
  131. package/dist/types/data-sync.js +4 -1
  132. package/dist/types/index.cjs +4 -1
  133. package/dist/utils/presentation-for.d.ts +6 -0
  134. package/dist/utils/presentation-for.d.ts.map +1 -0
  135. package/dist/utils/presentation-for.js +7 -0
  136. package/package.json +1 -1
  137. package/src/components/features/cloud-tenants/index.ts +8 -3
  138. package/src/components/features/cloud-tenants/single-customer-tenants-table-view.tsx +234 -0
  139. package/src/components/features/cloud-tenants/tenant-access-cell.tsx +12 -5
  140. package/src/components/features/cloud-tenants/tenant-customer-cell.tsx +48 -12
  141. package/src/components/features/cloud-tenants/tenant-licenses-cell.tsx +28 -0
  142. package/src/components/features/cloud-tenants/tenant-presentation.ts +46 -4
  143. package/src/components/features/cloud-tenants/tenant-row.ts +38 -8
  144. package/src/components/features/cloud-tenants/tenants-table-columns.ts +8 -5
  145. package/src/components/features/cloud-tenants/tenants-table-view.tsx +123 -28
  146. package/src/components/features/devices/device-directory.ts +55 -0
  147. package/src/components/features/devices/devices-filter-toolbar.tsx +4 -1
  148. package/src/components/features/devices/devices-panel-view.tsx +12 -2
  149. package/src/components/features/devices/devices-table-columns.tsx +123 -8
  150. package/src/components/features/devices/index.ts +7 -0
  151. package/src/components/features/devices/types.ts +14 -0
  152. package/src/components/features/devices/use-tag-filter-modal.ts +32 -14
  153. package/src/components/help-center-pages/trust-center-page.tsx +1 -1
  154. package/src/components/navigation/app-layout.tsx +4 -4
  155. package/src/components/platform/tool-badge-mark.ts +1 -1
  156. package/src/components/ui/data-attribution.tsx +20 -8
  157. package/src/components/ui/date-picker.tsx +21 -3
  158. package/src/components/ui/index.ts +2 -0
  159. package/src/hooks/ui/use-content-breakpoint.ts +21 -2
  160. package/src/hooks/ui/use-media-query.ts +1 -0
  161. package/src/types/data-sync.ts +4 -1
  162. package/src/utils/presentation-for.ts +10 -0
  163. package/dist/chunk-UAI4VMEF.cjs +0 -46
@@ -23,6 +23,7 @@ import {
23
23
  } from '../../ui';
24
24
  import { DATA_TABLE_SKELETON_ROWS } from '../../ui/data-table/utils';
25
25
  import { OSTypeBadge } from '../os-type-badge';
26
+ import { directorySourceLabel, directoryUserLines, directoryUserLinkLabel } from './device-directory';
26
27
  import { getDeviceName } from './device-name';
27
28
  import { getDeviceStatusConfig } from './device-status';
28
29
  import { DEFAULT_VISIBLE_STATUSES } from './device-statuses';
@@ -129,6 +130,8 @@ export interface DevicesTableBodyProps<T extends DeviceRow = DeviceRow> {
129
130
  actionsColumn?: ColumnDef<T>;
130
131
  /** Column ids to drop from the base table columns (e.g. ['organization'] when scoped to a single org). */
131
132
  hideColumns?: string[];
133
+ /** Adds the USER and SOURCES columns, for a host whose tenant has directories. Default: neither. */
134
+ directoryColumns?: boolean;
132
135
  /** Column ids whose header filter is dropped while the column stays visible (e.g. ['status'] on the archive page). */
133
136
  disableColumnFilters?: string[];
134
137
  /** The facet query is still in flight, see `getDeviceTableColumns`. */
@@ -168,6 +171,7 @@ export function DevicesTableBody<T extends DeviceRow = DeviceRow>({
168
171
  onColumnFiltersChange,
169
172
  actionsColumn,
170
173
  hideColumns,
174
+ directoryColumns = false,
171
175
  disableColumnFilters,
172
176
  filtersPending,
173
177
  totalCount,
@@ -177,12 +181,16 @@ export function DevicesTableBody<T extends DeviceRow = DeviceRow>({
177
181
  const columns = useMemo<ColumnDef<T>[]>(() => {
178
182
  const hidden = new Set(hideColumns ?? []);
179
183
  const unfiltered = new Set(disableColumnFilters ?? []);
180
- const base = getDeviceTableColumns<T>(deviceFilters ?? null, filtersPending)
184
+ const base = getDeviceTableColumns<T>(deviceFilters ?? null, filtersPending, {
185
+ directoryColumns,
186
+ // Without the customer column there is room for the sources from where the customer's column would have started.
187
+ roomyDirectory: hidden.has('organization'),
188
+ })
181
189
  .filter(c => !c.id || !hidden.has(c.id))
182
190
  .map(c => (c.id && unfiltered.has(c.id) ? { ...c, meta: { ...c.meta, filter: undefined } } : c));
183
191
  const open = getDeviceOpenColumn<T>();
184
192
  return actionsColumn ? [...base, actionsColumn, open] : [...base, open];
185
- }, [deviceFilters, filtersPending, actionsColumn, hideColumns, disableColumnFilters]);
193
+ }, [deviceFilters, filtersPending, actionsColumn, hideColumns, directoryColumns, disableColumnFilters]);
186
194
 
187
195
  // While loading the table holds one page of empty rows, drawn with the real cells: every part of a row draws its
188
196
  // own loading form (`DataTable.Body`'s `loadingCells`), so a loading row is the loaded row's own layout.
@@ -257,11 +265,43 @@ function DeviceLastSeen({ lastSeen }: { lastSeen: string | undefined }) {
257
265
  );
258
266
  }
259
267
 
268
+ /** The name over its role, as the USER column reads it (Figma 865:55733); the empty mark when nobody is tied. */
269
+ function DirectoryUserCell({ device }: { device: DeviceRow }) {
270
+ const loading = useLoadingScope();
271
+ if (loading && !device.directoryUserLink) {
272
+ return <TextLineSkeleton type="h4" className="w-3/4" />;
273
+ }
274
+ const { name, detail } = directoryUserLines(device);
275
+ return (
276
+ <div className="flex min-w-0 flex-col justify-center">
277
+ <TruncateText>{name}</TruncateText>
278
+ {detail && (
279
+ <TruncateText variant="h6" tone="secondary">
280
+ {detail}
281
+ </TruncateText>
282
+ )}
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /** The directories that know the device, as tags (Figma 187:61663); nothing when none does. */
288
+ function DirectorySourcesCell({ sources }: { sources: readonly string[] | undefined }) {
289
+ return (
290
+ <div data-loading-optional className="flex min-w-0 flex-wrap gap-[var(--spacing-system-xxs)]">
291
+ {(sources ?? []).map(source => (
292
+ <Tag key={source} label={directorySourceLabel(source)} variant="grey" />
293
+ ))}
294
+ </div>
295
+ );
296
+ }
297
+
260
298
  export interface DeviceFilterColumn {
261
299
  key: string;
262
300
  label: string;
263
301
  filterable?: boolean;
264
302
  filterOptions?: DeviceFilterOption[];
303
+ /** Filters in the filter window from `lg` until `xl` too: its column's header has no funnel there. */
304
+ inWindowLgToXl?: boolean;
265
305
  }
266
306
 
267
307
  /** A device filter dropdown entry: `meta.filter.options` plus the facet count. */
@@ -277,6 +317,8 @@ function buildDeviceFilterOptions(deviceFilters?: DeviceFilters | null): {
277
317
  status: DeviceFilterOption[];
278
318
  os: DeviceFilterOption[];
279
319
  organization: DeviceFilterOption[];
320
+ user: DeviceFilterOption[];
321
+ sources: DeviceFilterOption[];
280
322
  } {
281
323
  // Show only DEFAULT_VISIBLE_STATUSES (DELETED and legacy ARCHIVED live on /devices/archive)
282
324
  const status = (deviceFilters?.statuses ?? [])
@@ -294,19 +336,56 @@ function buildDeviceFilterOptions(deviceFilters?: DeviceFilters | null): {
294
336
  })),
295
337
  );
296
338
 
297
- return { status, os, organization };
339
+ const user = (deviceFilters?.userLinks ?? []).map(link => ({
340
+ id: link.value,
341
+ label: directoryUserLinkLabel(link.value),
342
+ value: link.value,
343
+ count: link.count,
344
+ }));
345
+
346
+ const sources = (deviceFilters?.directorySources ?? []).map(source => ({
347
+ id: source.value,
348
+ label: directorySourceLabel(source.value),
349
+ value: source.value,
350
+ count: source.count,
351
+ }));
352
+
353
+ return { status, os, organization, user, sources };
354
+ }
355
+
356
+ /** The directory columns of a device list: the table and its filter window take the same ones. */
357
+ interface DeviceDirectoryColumnOptions {
358
+ directoryColumns?: boolean;
359
+ roomyDirectory?: boolean;
298
360
  }
299
361
 
300
362
  // Filter column metadata used by the external filter modal (useTagFilterModal).
301
363
  // Kept separate from the table ColumnDef because DataTable doesn't carry the
302
364
  // filter metadata that the external mobile filter modal needs.
303
- export function getDeviceFilterColumns(deviceFilters?: DeviceFilters | null): DeviceFilterColumn[] {
365
+ // `roomyDirectory` is the table's own (`getDeviceTableColumns`): beside the customer column SOURCES shows only from
366
+ // `xl`, and the header has no funnel for it from `lg` until then, so the window carries it there.
367
+ export function getDeviceFilterColumns(
368
+ deviceFilters?: DeviceFilters | null,
369
+ { directoryColumns = false, roomyDirectory = false }: DeviceDirectoryColumnOptions = {},
370
+ ): DeviceFilterColumn[] {
304
371
  const options = buildDeviceFilterOptions(deviceFilters);
305
372
  return [
306
373
  {
307
374
  key: 'device',
308
375
  label: 'DEVICE',
309
376
  },
377
+ ...(directoryColumns
378
+ ? [
379
+ { key: 'user', label: 'USER', filterable: true, filterOptions: options.user },
380
+ {
381
+ key: 'sources',
382
+ label: 'SOURCES',
383
+ filterable: true,
384
+ filterOptions: options.sources,
385
+ inWindowLgToXl: !roomyDirectory,
386
+ },
387
+ ]
388
+ : []),
310
389
  { key: 'status', label: 'STATUS', filterable: true, filterOptions: options.status },
311
390
  { key: 'os', label: 'OS', filterable: true, filterOptions: options.os },
312
391
  { key: 'organization', label: 'CUSTOMER', filterable: true, filterOptions: options.organization },
@@ -318,17 +397,51 @@ export function getDeviceFilterColumns(deviceFilters?: DeviceFilters | null): De
318
397
  * funnels drawn (inert) rather than growing them when it answers. Absent means
319
398
  * these are the final options: an empty one then hides its funnel, which is what
320
399
  * a list with no facet source of its own wants.
400
+ * @param options.directoryColumns Adds USER and SOURCES after DEVICE. Off by default: a picker or a tenant with no
401
+ * directories has neither.
402
+ * @param options.roomyDirectory No customer column beside them (one customer's list), so SOURCES shows from `lg`
403
+ * rather than `xl`.
321
404
  */
322
405
  export function getDeviceTableColumns<T extends DeviceRow = DeviceRow>(
323
406
  deviceFilters?: DeviceFilters | null,
324
407
  filtersPending?: boolean,
408
+ { directoryColumns = false, roomyDirectory = false }: DeviceDirectoryColumnOptions = {},
325
409
  ): ColumnDef<T>[] {
326
410
  const {
327
411
  status: statusFilterOptions,
328
412
  os: osFilterOptions,
329
413
  organization: orgFilterOptions,
414
+ user: userFilterOptions,
415
+ sources: sourcesFilterOptions,
330
416
  } = buildDeviceFilterOptions(deviceFilters);
331
417
 
418
+ const directory: ColumnDef<T>[] = directoryColumns
419
+ ? [
420
+ {
421
+ id: 'user',
422
+ header: 'USER',
423
+ cell: ({ row }: { row: Row<T> }) => <DirectoryUserCell device={row.original} />,
424
+ enableSorting: false,
425
+ meta: {
426
+ width: 'min-w-0 content-lg:w-[15%]',
427
+ hideAt: 'lg',
428
+ filter: { options: userFilterOptions, pending: filtersPending },
429
+ },
430
+ },
431
+ {
432
+ id: 'sources',
433
+ header: 'SOURCES',
434
+ cell: ({ row }: { row: Row<T> }) => <DirectorySourcesCell sources={row.original.directorySources} />,
435
+ enableSorting: false,
436
+ meta: {
437
+ width: 'min-w-0 content-lg:w-[11%]',
438
+ hideAt: roomyDirectory ? 'lg' : 'xl',
439
+ filter: { options: sourcesFilterOptions, pending: filtersPending },
440
+ },
441
+ },
442
+ ]
443
+ : [];
444
+
332
445
  return [
333
446
  {
334
447
  accessorKey: 'device',
@@ -347,8 +460,10 @@ export function getDeviceTableColumns<T extends DeviceRow = DeviceRow>(
347
460
  </div>
348
461
  );
349
462
  },
350
- meta: { width: 'flex-1 content-md:w-1/4' },
463
+ // The name takes what the other columns leave: with two more of them, their shares below shrink to leave some.
464
+ meta: { width: directoryColumns ? 'flex-1 min-w-0' : 'flex-1 content-md:w-1/4' },
351
465
  },
466
+ ...directory,
352
467
  {
353
468
  accessorKey: 'status',
354
469
  id: 'status',
@@ -366,7 +481,7 @@ export function getDeviceTableColumns<T extends DeviceRow = DeviceRow>(
366
481
  );
367
482
  },
368
483
  meta: {
369
- width: 'w-auto shrink-0 content-md:w-1/5',
484
+ width: directoryColumns ? 'w-auto shrink-0 content-md:w-[14%]' : 'w-auto shrink-0 content-md:w-1/5',
370
485
  // Declared unconditionally, empty options and all. `meta.filter` is what
371
486
  // keeps a header cell visible below `lg`, so dropping it until the
372
487
  // options query resolves made every header on a tablet disappear on
@@ -383,7 +498,7 @@ export function getDeviceTableColumns<T extends DeviceRow = DeviceRow>(
383
498
  <OSTypeBadge osType={row.original.osType} iconSize="w-4 h-4 content-md:w-6 content-md:h-6" />
384
499
  ),
385
500
  meta: {
386
- width: 'w-[200px] content-md:w-1/6',
501
+ width: directoryColumns ? 'w-[200px] content-md:w-[10%]' : 'w-[200px] content-md:w-1/6',
387
502
  hideAt: 'md',
388
503
  filter: { options: osFilterOptions, pending: filtersPending },
389
504
  },
@@ -394,7 +509,7 @@ export function getDeviceTableColumns<T extends DeviceRow = DeviceRow>(
394
509
  header: 'CUSTOMER',
395
510
  cell: ({ row }: { row: Row<T> }) => <OrganizationCell device={row.original} />,
396
511
  meta: {
397
- width: 'w-1/6',
512
+ width: directoryColumns ? 'w-[14%]' : 'w-1/6',
398
513
  hideAt: 'lg',
399
514
  filter: { options: orgFilterOptions, placement: 'bottom-end', pending: filtersPending },
400
515
  },
@@ -1,5 +1,12 @@
1
1
  'use client';
2
2
 
3
+ export {
4
+ type DeviceDirectorySource,
5
+ type DeviceDirectoryUserLink,
6
+ directorySourceLabel,
7
+ directoryUserLines,
8
+ directoryUserLinkLabel,
9
+ } from './device-directory';
3
10
  export { formatLastOnline } from './device-last-online';
4
11
  export { type DeviceNameSource, getDeviceName, matchesDeviceName } from './device-name';
5
12
  export {
@@ -34,6 +34,14 @@ export interface DeviceRow extends DeviceNameSource {
34
34
  organizationImageUrl?: string | null;
35
35
  organizationImageHash?: string | null;
36
36
  tags?: DeviceTag[];
37
+ /** Who the device is tied to in a directory: PRIMARY, SHARED or NONE. The USER column reads it. */
38
+ directoryUserLink?: string;
39
+ /** The primary user's name; only PRIMARY has one. */
40
+ directoryUserName?: string | null;
41
+ /** How many users are tied to it: "N recent users" when SHARED. */
42
+ directoryUserCount?: number;
43
+ /** The directories that know the device besides the agent. The SOURCES column reads it. */
44
+ directorySources?: readonly string[];
37
45
  /** The viewer's unread notifications about this device. Absent or 0: no indicator. */
38
46
  unreadNotificationCount?: number;
39
47
  }
@@ -63,6 +71,10 @@ export interface DeviceFilters {
63
71
  organizationIds: DeviceFilterTag[];
64
72
  tagKeys: TagFilterOption[];
65
73
  filteredCount: number;
74
+ /** Behind the USER funnel; only hosts that draw the directory columns read it. */
75
+ userLinks?: DeviceFilterValue[];
76
+ /** Behind the SOURCES funnel; only hosts that draw the directory columns read it. */
77
+ directorySources?: DeviceFilterValue[];
66
78
  }
67
79
 
68
80
  /** What narrows a device list, in the backend's field names. */
@@ -73,4 +85,6 @@ export interface DeviceFilterInput {
73
85
  organizationIds?: string[];
74
86
  tagKeys?: string[];
75
87
  tagValues?: string[];
88
+ userLinks?: string[];
89
+ directorySources?: string[];
76
90
  }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useMemo, useRef, useState } from 'react';
4
- import { useMdUp } from '../../../hooks/ui/use-media-query';
4
+ import { useContentLgUp, useContentMdUp, useContentXlUp } from '../../../hooks/ui/use-content-breakpoint';
5
5
  import type { DeviceFilters } from './types';
6
6
 
7
7
  function isCompleteTag(tag: string): boolean {
@@ -19,7 +19,13 @@ interface FilterOption {
19
19
  interface UseTagFilterModalParams {
20
20
  tags: string[];
21
21
  deviceFilters: DeviceFilters | null;
22
- columns: Array<{ key: string; label: string; filterable?: boolean; filterOptions?: FilterOption[] }>;
22
+ columns: Array<{
23
+ key: string;
24
+ label: string;
25
+ filterable?: boolean;
26
+ filterOptions?: FilterOption[];
27
+ inWindowLgToXl?: boolean;
28
+ }>;
23
29
  /** `useApiParams`' setter, each device list param is a list of ids. */
24
30
  setParams: (params: Record<string, string[]>) => void;
25
31
  /**
@@ -39,7 +45,11 @@ export function useTagFilterModal({
39
45
  setParams,
40
46
  onApplied = scrollMainToTop,
41
47
  }: UseTagFilterModalParams) {
42
- const isMdUp = useMdUp();
48
+ // The content area's step, not the window's: the table header follows it, and a docked side panel narrows it while
49
+ // the window stays wide.
50
+ const isMdUp = useContentMdUp();
51
+ const lgUp = useContentLgUp();
52
+ const xlUp = useContentXlUp();
43
53
  const [isOpen, setIsOpen] = useState(false);
44
54
 
45
55
  // FilterModal calls onFilterChange first, then onTagsChange.
@@ -49,16 +59,19 @@ export function useTagFilterModal({
49
59
  const open = useCallback(() => setIsOpen(true), []);
50
60
  const close = useCallback(() => setIsOpen(false), []);
51
61
 
52
- const filterGroups = useMemo(() => {
53
- if (isMdUp) return [];
54
- return columns
55
- .filter(column => column.filterable)
56
- .map(column => ({
57
- id: column.key,
58
- title: column.label,
59
- options: column.filterOptions || [],
60
- }));
61
- }, [columns, isMdUp]);
62
+ // Below md the table draws no header, so every column filters here; from lg until xl only the columns whose header
63
+ // has no funnel there do.
64
+ const filterGroups = useMemo(
65
+ () =>
66
+ columns
67
+ .filter(column => column.filterable && (!isMdUp || (lgUp && xlUp === false && column.inWindowLgToXl)))
68
+ .map(column => ({
69
+ id: column.key,
70
+ title: column.label,
71
+ options: column.filterOptions || [],
72
+ })),
73
+ [columns, isMdUp, lgUp, xlUp],
74
+ );
62
75
 
63
76
  const tagFilterKeys = useMemo(() => {
64
77
  if (!deviceFilters?.tagKeys) return [];
@@ -79,6 +92,9 @@ export function useTagFilterModal({
79
92
  pendingFiltersRef.current = columnFilters;
80
93
  }, []);
81
94
 
95
+ // Only a host that draws the directory columns has the USER and SOURCES groups: another's list params stay untouched
96
+ const hasDirectoryGroups = useMemo(() => columns.some(column => column.key === 'user'), [columns]);
97
+
82
98
  // Step 2: combine with buffered filters and flush (called second by FilterModal)
83
99
  const handleTagsChange = useCallback(
84
100
  (newTags: string[]) => {
@@ -87,12 +103,14 @@ export function useTagFilterModal({
87
103
  statuses: cf.status || [],
88
104
  osTypes: cf.os || [],
89
105
  organizationIds: cf.organization || [],
106
+ // FilterModal drops a group with nothing ticked, so a missing group clears its param
107
+ ...(hasDirectoryGroups && { userLinks: cf.user || [], directorySources: cf.sources || [] }),
90
108
  tags: newTags.filter(isCompleteTag),
91
109
  });
92
110
  pendingFiltersRef.current = {};
93
111
  onApplied();
94
112
  },
95
- [setParams, onApplied],
113
+ [setParams, onApplied, hasDirectoryGroups],
96
114
  );
97
115
 
98
116
  return {
@@ -103,7 +103,7 @@ export interface TrustCenterPageProps {
103
103
  */
104
104
  beforeFaq?: ReactNode;
105
105
  /**
106
- * The "Data synced from <source>" line and its PDF download in the status row. Default true. Pass false when the
106
+ * The "Data from <source>" line and its PDF download in the status row. Default true. Pass false when the
107
107
  * host's own header states both (the hub's admin preview draws them in its page header), so the page never says
108
108
  * them twice; the monitoring status stays.
109
109
  */
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import type { CSSProperties, ReactNode } from 'react';
5
- import { ContentAreaWidthContext, contentBreakpointFor } from '../../hooks/ui/use-content-breakpoint';
5
+ import { ContentAreaWidthContext, contentStepFor } from '../../hooks/ui/use-content-breakpoint';
6
6
  import { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect';
7
7
  import type { NavigationSidebarConfig } from '../../types/navigation';
8
8
  import { cn } from '../../utils';
@@ -203,14 +203,14 @@ export function AppLayout({
203
203
 
204
204
  useIsomorphicLayoutEffect(() => {
205
205
  if (!adaptive || !mainElement) return undefined;
206
- // Every reader asks for the breakpoint (`useContentBreakpoint`), so only a
207
- // crossing re-renders the layout: kept at the old width, the state bails out.
206
+ // Every reader asks for a step (`useContentBreakpoint`, `useContentXlUp`), so
207
+ // only a crossing re-renders the layout: kept at the old width, the state bails out.
208
208
  // Per pixel, the uncompiled layout and every breakpoint consumer re-rendered on
209
209
  // each frame of a sidebar toggle, a window drag or a side-panel resize.
210
210
  const update = () => {
211
211
  const width = mainElement.clientWidth;
212
212
  setContentWidth(previous =>
213
- previous !== null && contentBreakpointFor(previous) === contentBreakpointFor(width) ? previous : width,
213
+ previous !== null && contentStepFor(previous) === contentStepFor(width) ? previous : width,
214
214
  );
215
215
  };
216
216
  update();
@@ -1,6 +1,6 @@
1
1
  import { ICON_SIZE } from '../../styles/component-sizes';
2
2
  /**
3
- * A tool or source mark's 16px box (`ToolBadge`'s mark, the "Data synced from" line's source glyph): read by the marks
3
+ * A tool or source mark's 16px box (`ToolBadge`'s mark, the "Data from" line's source glyph): read by the marks
4
4
  * and by every skeleton that stands for one. A leaf, so a `ui` component can read it without loading the badge.
5
5
  */
6
6
  export const TOOL_MARK_CLASS = `flex ${ICON_SIZE.sm} shrink-0 items-center justify-center`;
@@ -7,6 +7,9 @@ import { formatDate } from '../../utils/format';
7
7
  import { ClickupIcon } from '../icons-v2-generated/brand-logos/clickup-icon';
8
8
  import { GithubClassicIcon } from '../icons-v2-generated/brand-logos/github-classic-icon';
9
9
  import { HubspotIcon } from '../icons-v2-generated/brand-logos/hubspot-icon';
10
+ import { OpenframeLogoGreyIcon } from '../icons-v2-generated/brand-logos/openframe-logo-grey-icon';
11
+ import { QuickbooksIcon } from '../icons-v2-generated/brand-logos/quickbooks-icon';
12
+ import { StripeIcon } from '../icons-v2-generated/brand-logos/stripe-icon';
10
13
  import { TextLineSkeleton, UnifiedSkeleton } from '../loading/unified-skeleton';
11
14
  import { TOOL_MARK_CLASS } from '../platform/tool-badge-mark';
12
15
  import { useLoadingScope } from './loading-scope';
@@ -39,8 +42,8 @@ function Separator() {
39
42
  }
40
43
 
41
44
  /**
42
- * THE "Data synced from <source> · Last updated: <date>" line every synced
43
- * surface shows (the cap table, the financials, the Trust Center). The date is
45
+ * THE "Data from <source> · Last updated: <date>" line every surface showing another system's data
46
+ * draws (the cap table, the financials, the Trust Center). The date is
44
47
  * UTC (`formatDate`), so a server-rendered page and its hydrated copy print the
45
48
  * same text.
46
49
  */
@@ -50,7 +53,7 @@ export function DataAttribution({ icon, source, lastUpdated, action }: DataAttri
50
53
  <div className={LINE_CLASS}>
51
54
  <div className={ITEM_CLASS}>
52
55
  {icon}
53
- <span>Data synced from {source}</span>
56
+ <span>Data from {source}</span>
54
57
  </div>
55
58
  <Separator />
56
59
  <span className={UPDATED_CLASS}>Last updated: {lastUpdated ? formatDate(lastUpdated) : 'Never'}</span>
@@ -70,7 +73,7 @@ function DataAttributionSkeleton({ icon, action }: Pick<DataAttributionProps, 'i
70
73
  <div className={ITEM_CLASS}>
71
74
  {icon ?? <UnifiedSkeleton className={cn(TOOL_MARK_CLASS, 'rounded')} />}
72
75
  <span>
73
- Data synced from <TextLineSkeleton type="inherit" as="span" className={`${WORD_BAR_CLASS} w-20`} />
76
+ Data from <TextLineSkeleton type="inherit" as="span" className={`${WORD_BAR_CLASS} w-20`} />
74
77
  </span>
75
78
  </div>
76
79
  <Separator />
@@ -88,12 +91,22 @@ function DataAttributionSkeleton({ icon, action }: Pick<DataAttributionProps, 'i
88
91
  }
89
92
 
90
93
  /** The mark each sync source is drawn with (the app's standard brand marks); its name is `DATA_SYNC_SOURCES`'. */
91
- const DATA_SYNC_ICONS: Record<DataSyncSource, typeof ClickupIcon> = {
94
+ const DATA_SYNC_ICONS: Record<DataSyncSource, (props: { size?: number }) => ReactNode> = {
92
95
  clickup: ClickupIcon,
93
96
  hubspot: HubspotIcon,
94
97
  github: GithubClassicIcon,
98
+ // The OpenFrame mark drawn in `currentColor`, so it reads as the text beside it in both themes.
99
+ openframe: OpenframeLogoGreyIcon,
100
+ stripe: StripeIcon,
101
+ quickbooks: QuickbooksIcon,
95
102
  };
96
103
 
104
+ /** A sync source's mark, the one `DataSyncLine` draws: for a page that names the source in its own words. */
105
+ export function DataSyncIcon({ source, size = 16 }: { source: DataSyncSource; size?: number }) {
106
+ const Icon = DATA_SYNC_ICONS[source];
107
+ return <Icon size={size} />;
108
+ }
109
+
97
110
  /**
98
111
  * THE sync line of a shared page whose records an API reads from a mirror (the roadmap and the delivery board:
99
112
  * ClickUp; the ticket center: HubSpot; the docs and the legal documents: GitHub). The page draws whatever `sync`
@@ -121,13 +134,12 @@ export function DataSyncLine({
121
134
  const inScope = useLoadingScope();
122
135
  const loading = (loadingProp || inScope) && stated;
123
136
  if (!loading && !(sync && sync.source in DATA_SYNC_ICONS)) return null;
124
- const Icon = sync ? DATA_SYNC_ICONS[sync.source] : null;
125
137
  const line =
126
- loading || !sync || !Icon ? (
138
+ loading || !sync ? (
127
139
  <DataAttributionSkeleton action={action} />
128
140
  ) : (
129
141
  <DataAttribution
130
- icon={<Icon size={16} />}
142
+ icon={<DataSyncIcon source={sync.source} />}
131
143
  source={DATA_SYNC_SOURCES[sync.source]}
132
144
  lastUpdated={sync.syncedAt}
133
145
  action={action}
@@ -42,6 +42,8 @@ export interface DatePickerBaseProps {
42
42
  error?: string;
43
43
  /** When true, renders red error border */
44
44
  invalid?: boolean;
45
+ /** The trigger button's id: a label drawn outside the picker names it with `htmlFor`. */
46
+ id?: string;
45
47
  }
46
48
 
47
49
  export interface SingleDatePickerProps extends DatePickerBaseProps {
@@ -69,6 +71,10 @@ export type DatePickerProps = SingleDatePickerProps | RangeDatePickerProps;
69
71
  const defaultFormatDate = (date: Date): string =>
70
72
  formatDateWithTimezone(date, VIEWER_TIMEZONE, 'numeric', { viewerLocale: true });
71
73
 
74
+ // A label tied to the trigger (`id`) becomes the button's whole accessible name, so the date it shows would go unread:
75
+ // the trigger is described by its value instead.
76
+ const triggerValueId = (id: string | undefined) => (id ? `${id}-value` : undefined);
77
+
72
78
  const formatDateRange = (range: DateRange | undefined, formatFn: (date: Date) => string): string => {
73
79
  if (!range?.from) return '';
74
80
  if (!range.to) return formatFn(range.from);
@@ -667,6 +673,7 @@ export function DatePicker(props: DatePickerProps) {
667
673
  label,
668
674
  error,
669
675
  invalid = false,
676
+ id,
670
677
  } = props;
671
678
 
672
679
  const [open, setOpen] = useState(false);
@@ -701,6 +708,8 @@ export function DatePicker(props: DatePickerProps) {
701
708
  <Button
702
709
  variant="field"
703
710
  type="button"
711
+ id={id}
712
+ aria-describedby={triggerValueId(id)}
704
713
  disabled={disabled}
705
714
  // Same marker Input/Textarea/Select expose — it is how a form finds
706
715
  // (and scrolls to) the first field that failed validation.
@@ -709,6 +718,7 @@ export function DatePicker(props: DatePickerProps) {
709
718
  >
710
719
  <Calendar className="size-6 shrink-0 text-ods-text-secondary" />
711
720
  <span
721
+ id={triggerValueId(id)}
712
722
  className={cn(
713
723
  'flex-1 truncate text-left',
714
724
  disabled ? 'text-ods-text-disabled' : displayValue ? 'text-ods-text-primary' : 'text-ods-text-secondary',
@@ -749,7 +759,7 @@ export function DatePicker(props: DatePickerProps) {
749
759
  );
750
760
 
751
761
  return (
752
- <FieldWrapper label={label} error={error} errorSlot={'error' in props}>
762
+ <FieldWrapper label={label} htmlFor={id} error={error} errorSlot={'error' in props}>
753
763
  {picker}
754
764
  </FieldWrapper>
755
765
  );
@@ -799,6 +809,7 @@ export function DatePickerInput(props: DatePickerInputProps) {
799
809
  label,
800
810
  error,
801
811
  invalid = false,
812
+ id,
802
813
  } = props;
803
814
  const [open, setOpen] = useState(false);
804
815
  const portalContainer = usePortalContainer();
@@ -883,6 +894,8 @@ export function DatePickerInput(props: DatePickerInputProps) {
883
894
  <Button
884
895
  variant="field"
885
896
  type="button"
897
+ id={id}
898
+ aria-describedby={triggerValueId(id)}
886
899
  disabled={disabled}
887
900
  // Same marker Input/Textarea/Select expose — it is how a form finds
888
901
  // (and scrolls to) the first field that failed validation.
@@ -891,6 +904,7 @@ export function DatePickerInput(props: DatePickerInputProps) {
891
904
  >
892
905
  <Calendar className="size-6 shrink-0 text-ods-text-secondary" />
893
906
  <span
907
+ id={triggerValueId(id)}
894
908
  className={cn(
895
909
  'flex-1 truncate text-left',
896
910
  disabled
@@ -984,7 +998,7 @@ export function DatePickerInput(props: DatePickerInputProps) {
984
998
  );
985
999
 
986
1000
  return (
987
- <FieldWrapper label={label} error={error} errorSlot={'error' in props} className={className}>
1001
+ <FieldWrapper label={label} htmlFor={id} error={error} errorSlot={'error' in props} className={className}>
988
1002
  {content}
989
1003
  </FieldWrapper>
990
1004
  );
@@ -1050,6 +1064,7 @@ export function DatePickerInputSimple(props: DatePickerInputSimpleProps) {
1050
1064
  label,
1051
1065
  error,
1052
1066
  invalid = false,
1067
+ id,
1053
1068
  } = props;
1054
1069
  const [open, setOpen] = useState(false);
1055
1070
  const portalContainer = usePortalContainer();
@@ -1112,6 +1127,8 @@ export function DatePickerInputSimple(props: DatePickerInputSimpleProps) {
1112
1127
  <Button
1113
1128
  variant="field"
1114
1129
  type="button"
1130
+ id={id}
1131
+ aria-describedby={triggerValueId(id)}
1115
1132
  disabled={disabled}
1116
1133
  // Same marker Input/Textarea/Select expose — it is how a form finds
1117
1134
  // (and scrolls to) the first field that failed validation.
@@ -1120,6 +1137,7 @@ export function DatePickerInputSimple(props: DatePickerInputSimpleProps) {
1120
1137
  >
1121
1138
  <Calendar className="size-6 shrink-0 text-ods-text-secondary" />
1122
1139
  <span
1140
+ id={triggerValueId(id)}
1123
1141
  className={cn(
1124
1142
  'flex-1 truncate text-left',
1125
1143
  disabled
@@ -1181,7 +1199,7 @@ export function DatePickerInputSimple(props: DatePickerInputSimpleProps) {
1181
1199
  );
1182
1200
 
1183
1201
  return (
1184
- <FieldWrapper label={label} error={error} errorSlot={'error' in props} className={className}>
1202
+ <FieldWrapper label={label} htmlFor={id} error={error} errorSlot={'error' in props} className={className}>
1185
1203
  {content}
1186
1204
  </FieldWrapper>
1187
1205
  );
@@ -112,6 +112,8 @@ export * from './device-card';
112
112
  export * from './device-card-compact';
113
113
  export * from './entity-image';
114
114
  export * from './data-attribution';
115
+ // The sources `DataSyncLine` / `DataSyncIcon` take, beside them, so a page draws a source's name and mark from one import.
116
+ export * from '../../types/data-sync';
115
117
  export * from './download-button';
116
118
  export * from './compliance-badge';
117
119
  export * from './feature-card';