@flamingo-stack/openframe-frontend-core 0.0.853 → 0.0.855

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 (214) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{case-study-card-WP5Z234Q.cjs → case-study-card-6VAMNHLU.cjs} +8 -8
  3. package/dist/{chunk-2W22LY3W.cjs → chunk-2JCA7BRK.cjs} +19 -19
  4. package/dist/{chunk-53QPG7SJ.cjs → chunk-2MCPK6AX.cjs} +28 -28
  5. package/dist/{chunk-QOW2GP3V.cjs → chunk-2P72KAG5.cjs} +23 -23
  6. package/dist/{chunk-NMWLXHFH.cjs → chunk-3GJ75OUL.cjs} +183 -183
  7. package/dist/{chunk-P63CRPWI.cjs → chunk-4H3J57AN.cjs} +25 -25
  8. package/dist/{chunk-6GWRPW6B.cjs → chunk-5NK3FUHY.cjs} +4 -4
  9. package/dist/{chunk-DLTPSP5F.cjs → chunk-5TYG5JNB.cjs} +2 -2
  10. package/dist/{chunk-EIACEY2L.cjs → chunk-65NMKAPQ.cjs} +12 -12
  11. package/dist/{chunk-IGVAPIEW.cjs → chunk-6C4OR4MZ.cjs} +2 -2
  12. package/dist/{chunk-GG466XFQ.cjs → chunk-6DMH4GXN.cjs} +30 -5
  13. package/dist/{chunk-EF2FZ7GT.cjs → chunk-6VVQJU76.cjs} +54 -54
  14. package/dist/{chunk-G7GF4PWA.cjs → chunk-77GX573I.cjs} +9 -9
  15. package/dist/{chunk-7RZHSBNX.cjs → chunk-AOOHJLXS.cjs} +90 -90
  16. package/dist/{chunk-O3EKOXFZ.cjs → chunk-AYMY2VNB.cjs} +37 -43
  17. package/dist/{chunk-67CZ2Q5K.cjs → chunk-BSPIGZCC.cjs} +36 -36
  18. package/dist/{chunk-FW7RKWPY.cjs → chunk-D6GEMVHD.cjs} +3 -3
  19. package/dist/{chunk-KEV6F3QN.cjs → chunk-DPNFI5XM.cjs} +100 -100
  20. package/dist/{chunk-6V2M2X3M.cjs → chunk-DTZ6KASI.cjs} +6 -6
  21. package/dist/{chunk-SSKMGH5O.cjs → chunk-DXVGGWLG.cjs} +10 -10
  22. package/dist/{chunk-IO6NN3SL.cjs → chunk-EOUEUYCQ.cjs} +54 -54
  23. package/dist/{chunk-5RMBLOJ5.cjs → chunk-ESGE7CUX.cjs} +14 -14
  24. package/dist/{chunk-ETZFBB7O.cjs → chunk-EXQSIX2I.cjs} +4 -4
  25. package/dist/{chunk-IC4IJJB2.cjs → chunk-F33EAPD2.cjs} +124 -124
  26. package/dist/{chunk-COWMXUHN.cjs → chunk-GK577ZLJ.cjs} +6 -14
  27. package/dist/{chunk-E4MFQKPC.cjs → chunk-GZYRDLJW.cjs} +103 -103
  28. package/dist/{chunk-LFEURIZN.cjs → chunk-HML3JUXF.cjs} +324 -322
  29. package/dist/{chunk-BNHTBLAG.cjs → chunk-J3CFRGZ2.cjs} +17 -17
  30. package/dist/{chunk-USPUZO53.cjs → chunk-KZAQPRTA.cjs} +9 -11
  31. package/dist/{chunk-HTYJUMFB.cjs → chunk-LSLRJJFP.cjs} +2 -2
  32. package/dist/{chunk-TBGQMHCG.cjs → chunk-MINFN3IG.cjs} +8 -8
  33. package/dist/{chunk-KHOB3TES.cjs → chunk-MX52LZ6M.cjs} +6 -6
  34. package/dist/{chunk-OH5TXFRR.cjs → chunk-O4CPT3RK.cjs} +7 -7
  35. package/dist/{chunk-HF3V6WBJ.cjs → chunk-OTHQ2KQW.cjs} +3 -3
  36. package/dist/{chunk-IUDBG3HJ.cjs → chunk-PCQ2HKU3.cjs} +5 -5
  37. package/dist/{chunk-BNG6JOGZ.cjs → chunk-QPHXYE5P.cjs} +27 -27
  38. package/dist/{chunk-UO2EJGRJ.cjs → chunk-QZXCUY36.cjs} +53 -53
  39. package/dist/{chunk-COJG2ATY.cjs → chunk-RIJU4HZ2.cjs} +51 -55
  40. package/dist/{chunk-KKFDRCQF.cjs → chunk-RY7INTYU.cjs} +14 -1
  41. package/dist/{chunk-GI3MMBP3.cjs → chunk-SQ7YHWEP.cjs} +228 -15
  42. package/dist/{chunk-U767P4JU.cjs → chunk-STV6VJ3U.cjs} +21 -21
  43. package/dist/chunk-T54I5ZZY.cjs +62 -0
  44. package/dist/{chunk-UV45EPXN.cjs → chunk-TW5Y4D5I.cjs} +7 -7
  45. package/dist/{chunk-FX35L4Y6.cjs → chunk-XCQ5I27Z.cjs} +3 -3
  46. package/dist/{chunk-AZEO7HBB.cjs → chunk-Y44EF4JW.cjs} +2 -2
  47. package/dist/{chunk-W7OFAROY.cjs → chunk-YSXVRTGT.cjs} +9 -12
  48. package/dist/{chunk-GI6ECCWB.cjs → chunk-ZUGJLCYP.cjs} +1272 -856
  49. package/dist/{chunk-KNN2GCMW.cjs → chunk-ZYAVIRQV.cjs} +25 -25
  50. package/dist/{cloud-tenants-QUL2XPAS.cjs → cloud-tenants-2XFIDDGU.cjs} +28 -29
  51. package/dist/components/case-studies/index.cjs +44 -45
  52. package/dist/components/chat/chat-message-row.d.ts +1 -1
  53. package/dist/components/chat/chat-message-row.d.ts.map +1 -1
  54. package/dist/components/chat/chat-message-row.js +4 -4
  55. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts +7 -13
  56. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts.map +1 -1
  57. package/dist/components/chat/entity-cards/use-cover-image-fallback.js +2 -8
  58. package/dist/components/chat/hooks/use-chat-attachment-image-gallery.d.ts.map +1 -1
  59. package/dist/components/chat/hooks/use-chat-attachment-image-gallery.js +8 -4
  60. package/dist/components/chat/index.cjs +22 -23
  61. package/dist/components/contact/index.cjs +29 -30
  62. package/dist/components/docs/index.cjs +32 -33
  63. package/dist/components/embeds/index.cjs +29 -30
  64. package/dist/components/faq/index.cjs +30 -31
  65. package/dist/components/features/cloud-tenants/index.d.ts +7 -5
  66. package/dist/components/features/cloud-tenants/index.d.ts.map +1 -1
  67. package/dist/components/features/cloud-tenants/index.js +8 -1
  68. package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.d.ts +26 -0
  69. package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.d.ts.map +1 -0
  70. package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.js +176 -0
  71. package/dist/components/features/cloud-tenants/tenant-access-cell.d.ts +4 -2
  72. package/dist/components/features/cloud-tenants/tenant-access-cell.d.ts.map +1 -1
  73. package/dist/components/features/cloud-tenants/tenant-access-cell.js +3 -2
  74. package/dist/components/features/cloud-tenants/tenant-customer-cell.d.ts +12 -5
  75. package/dist/components/features/cloud-tenants/tenant-customer-cell.d.ts.map +1 -1
  76. package/dist/components/features/cloud-tenants/tenant-customer-cell.js +38 -7
  77. package/dist/components/features/cloud-tenants/tenant-licenses-cell.d.ts +7 -0
  78. package/dist/components/features/cloud-tenants/tenant-licenses-cell.d.ts.map +1 -0
  79. package/dist/components/features/cloud-tenants/tenant-licenses-cell.js +17 -0
  80. package/dist/components/features/cloud-tenants/tenant-presentation.d.ts +19 -0
  81. package/dist/components/features/cloud-tenants/tenant-presentation.d.ts.map +1 -1
  82. package/dist/components/features/cloud-tenants/tenant-presentation.js +24 -3
  83. package/dist/components/features/cloud-tenants/tenant-row.d.ts +35 -8
  84. package/dist/components/features/cloud-tenants/tenant-row.d.ts.map +1 -1
  85. package/dist/components/features/cloud-tenants/tenants-table-columns.d.ts +9 -3
  86. package/dist/components/features/cloud-tenants/tenants-table-columns.d.ts.map +1 -1
  87. package/dist/components/features/cloud-tenants/tenants-table-columns.js +5 -3
  88. package/dist/components/features/cloud-tenants/tenants-table-view.d.ts +27 -6
  89. package/dist/components/features/cloud-tenants/tenants-table-view.d.ts.map +1 -1
  90. package/dist/components/features/cloud-tenants/tenants-table-view.js +75 -17
  91. package/dist/components/features/devices/device-directory.d.ts +18 -0
  92. package/dist/components/features/devices/device-directory.d.ts.map +1 -0
  93. package/dist/components/features/devices/device-directory.js +35 -0
  94. package/dist/components/features/devices/devices-filter-toolbar.d.ts +3 -1
  95. package/dist/components/features/devices/devices-filter-toolbar.d.ts.map +1 -1
  96. package/dist/components/features/devices/devices-filter-toolbar.js +2 -1
  97. package/dist/components/features/devices/devices-panel-view.d.ts +3 -1
  98. package/dist/components/features/devices/devices-panel-view.d.ts.map +1 -1
  99. package/dist/components/features/devices/devices-panel-view.js +11 -2
  100. package/dist/components/features/devices/devices-table-columns.d.ts +17 -3
  101. package/dist/components/features/devices/devices-table-columns.d.ts.map +1 -1
  102. package/dist/components/features/devices/devices-table-columns.js +80 -10
  103. package/dist/components/features/devices/index.d.ts +1 -0
  104. package/dist/components/features/devices/index.d.ts.map +1 -1
  105. package/dist/components/features/devices/index.js +8 -0
  106. package/dist/components/features/devices/types.d.ts +14 -0
  107. package/dist/components/features/devices/types.d.ts.map +1 -1
  108. package/dist/components/features/devices/use-tag-filter-modal.d.ts +1 -0
  109. package/dist/components/features/devices/use-tag-filter-modal.d.ts.map +1 -1
  110. package/dist/components/features/devices/use-tag-filter-modal.js +13 -8
  111. package/dist/components/features/index.cjs +42 -29
  112. package/dist/components/help-center-pages/index.cjs +134 -135
  113. package/dist/components/index.cjs +126 -113
  114. package/dist/components/meeting-scheduler/index.cjs +102 -103
  115. package/dist/components/navigation/app-layout.js +2 -2
  116. package/dist/components/navigation/index.cjs +31 -32
  117. package/dist/components/onboarding-guides/index.cjs +31 -32
  118. package/dist/components/product-demo/index.cjs +39 -40
  119. package/dist/components/related-content/index.cjs +16 -17
  120. package/dist/components/tickets/index.cjs +35 -36
  121. package/dist/components/ui/date-picker.d.ts +2 -0
  122. package/dist/components/ui/date-picker.d.ts.map +1 -1
  123. package/dist/components/ui/date-picker.js +19 -6
  124. package/dist/components/ui/entity-image.d.ts +1 -2
  125. package/dist/components/ui/entity-image.d.ts.map +1 -1
  126. package/dist/components/ui/entity-image.js +4 -8
  127. package/dist/components/ui/index.cjs +28 -29
  128. package/dist/components/ui/markdown/base-components.d.ts +0 -13
  129. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  130. package/dist/components/ui/markdown/base-components.js +7 -7
  131. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  132. package/dist/components/ui/square-avatar.js +3 -8
  133. package/dist/{devices-LNB4CYPP.cjs → devices-O3DYGIZB.cjs} +29 -30
  134. package/dist/embed-shims/index.cjs +3 -2
  135. package/dist/embed-shims/next-image.cjs +3 -2
  136. package/dist/embed-shims/next-image.d.ts +2 -0
  137. package/dist/embed-shims/next-image.d.ts.map +1 -1
  138. package/dist/embed-shims/next-image.js +14 -4
  139. package/dist/{embeddable-chat-SJEEJI4N.cjs → embeddable-chat-QD7NFD2H.cjs} +100 -101
  140. package/dist/hooks/index.cjs +27 -16
  141. package/dist/hooks/index.d.ts +1 -0
  142. package/dist/hooks/index.d.ts.map +1 -1
  143. package/dist/hooks/index.js +1 -0
  144. package/dist/hooks/ui/use-content-breakpoint.d.ts +8 -0
  145. package/dist/hooks/ui/use-content-breakpoint.d.ts.map +1 -1
  146. package/dist/hooks/ui/use-content-breakpoint.js +17 -3
  147. package/dist/hooks/ui/use-media-query.d.ts +1 -0
  148. package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
  149. package/dist/hooks/ui/use-media-query.js +3 -1
  150. package/dist/hooks/use-authed-asset-src.d.ts +77 -2
  151. package/dist/hooks/use-authed-asset-src.d.ts.map +1 -1
  152. package/dist/hooks/use-authed-asset-src.js +185 -73
  153. package/dist/hooks/use-authed-image-src.d.ts +11 -4
  154. package/dist/hooks/use-authed-image-src.d.ts.map +1 -1
  155. package/dist/hooks/use-authed-image-src.js +6 -2
  156. package/dist/hooks/use-image-fallback.d.ts +16 -0
  157. package/dist/hooks/use-image-fallback.d.ts.map +1 -0
  158. package/dist/hooks/use-image-fallback.js +14 -0
  159. package/dist/index.cjs +58 -36
  160. package/dist/{knowledge-UTWKB6XG.cjs → knowledge-K7AFNZNO.cjs} +29 -30
  161. package/dist/{logs-PMXIWMBQ.cjs → logs-MZ3BR2FR.cjs} +35 -36
  162. package/dist/{policies-6DCMFDL7.cjs → policies-XVKQ2W7S.cjs} +32 -33
  163. package/dist/{remote-session-SG44CNOK.cjs → remote-session-VOZJXDM4.cjs} +13 -14
  164. package/dist/{scripts-2G7LXW7Z.cjs → scripts-IBPMBS6G.cjs} +35 -36
  165. package/dist/{software-update-MYLKHDHC.cjs → software-update-PXTANMAO.cjs} +32 -33
  166. package/dist/{tickets-board-SQY2Y6QV.cjs → tickets-board-POYZS7BW.cjs} +16 -17
  167. package/dist/utils/app-origins.d.ts +3 -6
  168. package/dist/utils/app-origins.d.ts.map +1 -1
  169. package/dist/utils/app-origins.js +8 -1
  170. package/dist/utils/embed-authed-fetch.d.ts +46 -26
  171. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  172. package/dist/utils/embed-authed-fetch.js +24 -8
  173. package/dist/utils/index.cjs +4 -1
  174. package/dist/utils/presentation-for.d.ts +6 -0
  175. package/dist/utils/presentation-for.d.ts.map +1 -0
  176. package/dist/utils/presentation-for.js +7 -0
  177. package/dist/{video-file-player-XMOB234V.cjs → video-file-player-RIOT24NT.cjs} +3 -4
  178. package/package.json +1 -1
  179. package/src/components/chat/chat-message-row.tsx +8 -7
  180. package/src/components/chat/entity-cards/use-cover-image-fallback.ts +8 -28
  181. package/src/components/chat/hooks/use-chat-attachment-image-gallery.tsx +19 -8
  182. package/src/components/features/cloud-tenants/index.ts +8 -3
  183. package/src/components/features/cloud-tenants/single-customer-tenants-table-view.tsx +234 -0
  184. package/src/components/features/cloud-tenants/tenant-access-cell.tsx +12 -5
  185. package/src/components/features/cloud-tenants/tenant-customer-cell.tsx +48 -12
  186. package/src/components/features/cloud-tenants/tenant-licenses-cell.tsx +28 -0
  187. package/src/components/features/cloud-tenants/tenant-presentation.ts +46 -4
  188. package/src/components/features/cloud-tenants/tenant-row.ts +38 -8
  189. package/src/components/features/cloud-tenants/tenants-table-columns.ts +8 -5
  190. package/src/components/features/cloud-tenants/tenants-table-view.tsx +123 -28
  191. package/src/components/features/devices/device-directory.ts +55 -0
  192. package/src/components/features/devices/devices-filter-toolbar.tsx +4 -1
  193. package/src/components/features/devices/devices-panel-view.tsx +12 -2
  194. package/src/components/features/devices/devices-table-columns.tsx +123 -8
  195. package/src/components/features/devices/index.ts +7 -0
  196. package/src/components/features/devices/types.ts +14 -0
  197. package/src/components/features/devices/use-tag-filter-modal.ts +32 -14
  198. package/src/components/navigation/app-layout.tsx +4 -4
  199. package/src/components/ui/date-picker.tsx +21 -3
  200. package/src/components/ui/entity-image.tsx +4 -12
  201. package/src/components/ui/markdown/base-components.tsx +15 -20
  202. package/src/components/ui/square-avatar.tsx +5 -8
  203. package/src/embed-shims/next-image.tsx +34 -3
  204. package/src/hooks/index.ts +1 -0
  205. package/src/hooks/ui/use-content-breakpoint.ts +21 -2
  206. package/src/hooks/ui/use-media-query.ts +1 -0
  207. package/src/hooks/use-authed-asset-src.ts +323 -107
  208. package/src/hooks/use-authed-image-src.ts +15 -5
  209. package/src/hooks/use-image-fallback.ts +31 -0
  210. package/src/utils/app-origins.ts +12 -7
  211. package/src/utils/embed-authed-fetch.ts +71 -32
  212. package/src/utils/presentation-for.ts +10 -0
  213. package/dist/chunk-UAI4VMEF.cjs +0 -46
  214. package/dist/chunk-YSDB6HGQ.cjs +0 -102
@@ -5,24 +5,37 @@ import { cn } from '../../../utils/cn';
5
5
  import { openInNewTab } from '../../../utils/open-in-new-tab';
6
6
  import { ArrowRightUpIcon } from '../../icons-v2-generated/arrows/arrow-right-up-icon';
7
7
  import { Button } from '../../ui/button';
8
- import { type ColumnDef, DataTable, dataTableLoadingRows, type Row, useDataTable } from '../../ui/data-table';
8
+ import {
9
+ type ColumnDef,
10
+ type ColumnFiltersState,
11
+ DataTable,
12
+ type DataTableFilterOption,
13
+ dataTableLoadingRows,
14
+ type Row,
15
+ useDataTable,
16
+ } from '../../ui/data-table';
9
17
  import { useLoadingScope } from '../../ui/loading-scope';
18
+ import {
19
+ SingleCustomerTenantsTableView,
20
+ type SingleCustomerTenantsTableViewProps,
21
+ } from './single-customer-tenants-table-view';
10
22
  import { TenantAccessCell } from './tenant-access-cell';
11
23
  import { TenantCell } from './tenant-cell';
12
24
  import { TenantCustomerCell } from './tenant-customer-cell';
13
- import type { TenantRow } from './tenant-row';
25
+ import { TenantLicensesCell } from './tenant-licenses-cell';
26
+ import type { MultiCustomerTenantRow } from './tenant-row';
14
27
  import { TENANT_COLUMNS, type TenantsTableColumn } from './tenants-table-columns';
15
28
 
16
- const getRowId = (row: TenantRow) => row.id;
29
+ const getRowId = (row: MultiCustomerTenantRow) => row.id;
17
30
 
18
31
  const columnMeta = ({ width, hideAt, align }: TenantsTableColumn) => ({ width, hideAt, align });
19
32
 
20
33
  /** A tenant still being read: a row the table keys, every field a cell shows empty (each draws its loading form). */
21
- const loadingTenant = (id: string): TenantRow => ({
34
+ const loadingTenant = (id: string): MultiCustomerTenantRow => ({
22
35
  id,
23
36
  name: '',
24
37
  provider: '',
25
- customer: { name: '' },
38
+ customers: [{ name: '' }],
26
39
  accessState: '',
27
40
  });
28
41
 
@@ -45,16 +58,35 @@ function OpenTenantButton({ href, name }: { href: string; name: string }) {
45
58
  );
46
59
  }
47
60
 
61
+ /** The columns whose header carries a funnel (Figma 3036-4860). */
62
+ export const TENANTS_TABLE_FILTER_COLUMNS = ['tenant', 'customer', 'licenses', 'access'] as const;
63
+
64
+ export type TenantsTableFilterColumn = (typeof TENANTS_TABLE_FILTER_COLUMNS)[number];
65
+
66
+ /** Each column's funnel: what it offers, what is ticked. The host narrows the rows; the view only draws. */
67
+ export interface TenantsTableFilters {
68
+ /** A column the host leaves out has no funnel; one with no options draws its bare label. */
69
+ options: Partial<Record<TenantsTableFilterColumn, DataTableFilterOption[]>>;
70
+ selections: Readonly<Record<TenantsTableFilterColumn, readonly string[]>>;
71
+ onChange: (next: Record<TenantsTableFilterColumn, string[]>) => void;
72
+ /** The options are in flight: the funnels draw, inert. */
73
+ pending?: boolean;
74
+ }
75
+
48
76
  /**
49
77
  * Click handler that opens `href` in a new tab. The row is itself the details
50
78
  * link and an `<a>` cannot be nested in an `<a>`, so the button opens it by script.
51
79
  */
52
- export interface TenantsTableViewProps {
53
- rows: TenantRow[];
80
+ interface MultiCustomerTenantsTableViewProps {
81
+ /** A directory per row that serves any number of customers, with its licences and the column funnels. */
82
+ variant: 'multi-customer';
83
+ rows: MultiCustomerTenantRow[];
54
84
  /** Where a row leads: the row is a link to it, and its last cell opens it in a new tab. */
55
- getHref: (row: TenantRow) => string;
85
+ getHref: (row: MultiCustomerTenantRow) => string;
56
86
  /** The search the rows answer; named by the "no match" message. */
57
87
  search?: string;
88
+ /** The funnels of the column headers. Absent: none, as on the product page. */
89
+ filters?: TenantsTableFilters;
58
90
  /** The rows on screen are the previous result of a search still loading: they dim. */
59
91
  isPending?: boolean;
60
92
  /** All matching tenants, loaded or not. Default: the rows given. */
@@ -68,14 +100,27 @@ export interface TenantsTableViewProps {
68
100
  now?: Date;
69
101
  }
70
102
 
103
+ /** The single-customer list unless `variant` is 'multi-customer', the one that takes funnels and needs its own rows. */
104
+ export type TenantsTableViewProps = SingleCustomerTenantsTableViewProps | MultiCustomerTenantsTableViewProps;
105
+
106
+ /** The tenants list, in the layout its `variant` names. */
107
+ export function TenantsTableView(props: TenantsTableViewProps) {
108
+ return props.variant === 'multi-customer' ? (
109
+ <MultiCustomerTenantsTableView {...props} />
110
+ ) : (
111
+ <SingleCustomerTenantsTableView {...props} />
112
+ );
113
+ }
114
+
71
115
  /** The rows a loading tenants table draws when the host states none (a typical page of tenants). */
72
116
  const TENANTS_TABLE_SKELETON_ROWS = 20;
73
117
 
74
- /** The tenants list: tenant, customer, access state and a way into each tenant's details. */
75
- export function TenantsTableView({
118
+ /** The tenants list: tenant, customers, licences, access state and a way into each tenant's details. */
119
+ function MultiCustomerTenantsTableView({
76
120
  rows,
77
121
  getHref,
78
122
  search = '',
123
+ filters,
79
124
  isPending = false,
80
125
  totalCount,
81
126
  stickyHeaderOffset,
@@ -84,51 +129,75 @@ export function TenantsTableView({
84
129
  isFetchingNextPage = false,
85
130
  onLoadMore,
86
131
  now,
87
- }: TenantsTableViewProps) {
88
- const columns = useMemo<ColumnDef<TenantRow>[]>(
89
- () => [
132
+ }: MultiCustomerTenantsTableViewProps) {
133
+ const filterOptions = filters?.options;
134
+ const filtersPending = filters?.pending ?? false;
135
+ const columns = useMemo<ColumnDef<MultiCustomerTenantRow>[]>(() => {
136
+ /**
137
+ * A column's funnel, while the host offers one. Declared with no options too: below lg only a filtered column keeps
138
+ * its header, and the column filter draws the bare label when there is nothing to choose.
139
+ */
140
+ const funnel = (id: TenantsTableFilterColumn) => {
141
+ const options = filterOptions?.[id];
142
+ return options ? { filter: { options, pending: filtersPending } } : {};
143
+ };
144
+ return [
90
145
  {
91
146
  id: TENANT_COLUMNS.tenant.id,
92
147
  header: TENANT_COLUMNS.tenant.header,
93
- cell: ({ row }: { row: Row<TenantRow> }) => (
148
+ cell: ({ row }: { row: Row<MultiCustomerTenantRow> }) => (
94
149
  <TenantCell provider={row.original.provider} name={row.original.name} domain={row.original.domain} />
95
150
  ),
96
151
  enableSorting: false,
97
- meta: columnMeta(TENANT_COLUMNS.tenant),
152
+ meta: { ...columnMeta(TENANT_COLUMNS.tenant), ...funnel('tenant') },
98
153
  },
99
154
  {
100
155
  id: TENANT_COLUMNS.customer.id,
101
156
  header: TENANT_COLUMNS.customer.header,
102
- cell: ({ row }: { row: Row<TenantRow> }) => (
157
+ cell: ({ row }: { row: Row<MultiCustomerTenantRow> }) => (
103
158
  <TenantCustomerCell
104
- name={row.original.customer.name}
105
- imageUrl={row.original.customer.imageUrl}
159
+ customers={row.original.customers}
106
160
  userCount={row.original.userCount}
161
+ unattributedUserCount={row.original.unattributedUserCount}
162
+ neverRead={row.original.lastReadAt == null && row.original.userCount == null}
107
163
  />
108
164
  ),
109
165
  enableSorting: false,
110
- meta: columnMeta(TENANT_COLUMNS.customer),
166
+ meta: { ...columnMeta(TENANT_COLUMNS.customer), ...funnel('customer') },
167
+ },
168
+ {
169
+ id: TENANT_COLUMNS.licenses.id,
170
+ header: TENANT_COLUMNS.licenses.header,
171
+ cell: ({ row }: { row: Row<MultiCustomerTenantRow> }) => (
172
+ <TenantLicensesCell licenses={row.original.licenses} />
173
+ ),
174
+ enableSorting: false,
175
+ meta: { ...columnMeta(TENANT_COLUMNS.licenses), ...funnel('licenses') },
111
176
  },
112
177
  {
113
178
  id: TENANT_COLUMNS.access.id,
114
179
  header: TENANT_COLUMNS.access.header,
115
- cell: ({ row }: { row: Row<TenantRow> }) => (
116
- <TenantAccessCell accessState={row.original.accessState} lastReadAt={row.original.lastReadAt} now={now} />
180
+ cell: ({ row }: { row: Row<MultiCustomerTenantRow> }) => (
181
+ <TenantAccessCell
182
+ accessState={row.original.accessState}
183
+ lastReadAt={row.original.lastReadAt}
184
+ syncPaused={row.original.syncPaused}
185
+ now={now}
186
+ />
117
187
  ),
118
188
  enableSorting: false,
119
- meta: columnMeta(TENANT_COLUMNS.access),
189
+ meta: { ...columnMeta(TENANT_COLUMNS.access), ...funnel('access') },
120
190
  },
121
191
  {
122
192
  id: TENANT_COLUMNS.open.id,
123
- cell: ({ row }: { row: Row<TenantRow> }) => (
193
+ cell: ({ row }: { row: Row<MultiCustomerTenantRow> }) => (
124
194
  <OpenTenantButton href={getHref(row.original)} name={row.original.name} />
125
195
  ),
126
196
  enableSorting: false,
127
197
  meta: columnMeta(TENANT_COLUMNS.open),
128
198
  },
129
- ],
130
- [getHref, now],
131
- );
199
+ ];
200
+ }, [getHref, now, filterOptions, filtersPending]);
132
201
 
133
202
  // A page's loading scope: the table holds one page of placeholder rows, drawn with the real cells (`loadingCells`).
134
203
  const loading = useLoadingScope();
@@ -136,7 +205,31 @@ export function TenantsTableView({
136
205
  () => (loading ? dataTableLoadingRows(skeletonRows, loadingTenant) : null),
137
206
  [loading, skeletonRows],
138
207
  );
139
- const table = useDataTable<TenantRow>({ data: loadingRows ?? rows, columns, getRowId, enableSorting: false });
208
+ const selections = filters?.selections;
209
+ const onFiltersChange = filters?.onChange;
210
+ const columnFilters = useMemo<ColumnFiltersState>(
211
+ () =>
212
+ TENANTS_TABLE_FILTER_COLUMNS.flatMap(id =>
213
+ selections && selections[id].length > 0 ? [{ id, value: selections[id] }] : [],
214
+ ),
215
+ [selections],
216
+ );
217
+ const table = useDataTable<MultiCustomerTenantRow>({
218
+ data: loadingRows ?? rows,
219
+ columns,
220
+ getRowId,
221
+ enableSorting: false,
222
+ state: { columnFilters },
223
+ onColumnFiltersChange: updater => {
224
+ const next = typeof updater === 'function' ? updater(columnFilters) : updater;
225
+ const picked = (id: TenantsTableFilterColumn) => {
226
+ const value = next.find(filter => filter.id === id)?.value;
227
+ return Array.isArray(value) ? (value as string[]) : [];
228
+ };
229
+ const selected = Object.fromEntries(TENANTS_TABLE_FILTER_COLUMNS.map(id => [id, picked(id)]));
230
+ onFiltersChange?.(selected as Record<TenantsTableFilterColumn, string[]>);
231
+ },
232
+ });
140
233
 
141
234
  return (
142
235
  // Dim, don't unmount, the stale rows while a deferred search refetches.
@@ -152,7 +245,9 @@ export function TenantsTableView({
152
245
  skeletonRows={skeletonRows}
153
246
  emptyState={{
154
247
  title: 'No tenants match',
155
- description: `Nothing matches "${search}". Try a different name, domain or customer.`,
248
+ description: search
249
+ ? `Nothing matches "${search}". Try a different name, domain or customer.`
250
+ : 'Nothing matches the selected filters. Try adjusting them.',
156
251
  }}
157
252
  rowClassName="mb-[var(--spacing-system-xxs)]"
158
253
  rowHref={getHref}
@@ -0,0 +1,55 @@
1
+ import { presentationFor } from '../../../utils/presentation-for';
2
+ import type { DeviceRow } from './types';
3
+
4
+ /** The schema's `DirectoryUserLink`: who a device is tied to in a directory. */
5
+ export type DeviceDirectoryUserLink = 'PRIMARY' | 'SHARED' | 'NONE';
6
+
7
+ /** The schema's `DirectorySource`: a directory that knows a device. */
8
+ export type DeviceDirectorySource = 'MICROSOFT_365' | 'GOOGLE_WORKSPACE';
9
+
10
+ const USER_LINK_LABELS = {
11
+ PRIMARY: 'Primary user',
12
+ SHARED: 'Shared',
13
+ NONE: 'No user',
14
+ } satisfies Record<DeviceDirectoryUserLink, string>;
15
+
16
+ const SOURCE_LABELS = {
17
+ MICROSOFT_365: 'M365',
18
+ GOOGLE_WORKSPACE: 'GOOGLE',
19
+ } satisfies Record<DeviceDirectorySource, string>;
20
+
21
+ /** The USER funnel's option (Figma 1136:64807). A value this build does not know keeps its own name. */
22
+ export function directoryUserLinkLabel(link: string): string {
23
+ return presentationFor(USER_LINK_LABELS, link) ?? link;
24
+ }
25
+
26
+ /** The SOURCES tag: which directory knows the device. */
27
+ export function directorySourceLabel(source: string): string {
28
+ return presentationFor(SOURCE_LABELS, source) ?? source;
29
+ }
30
+
31
+ const EMPTY_USER = '—';
32
+
33
+ // The row carries the wire's string: a member compared to it is typed here, so a misspelt one fails to compile.
34
+ const PRIMARY_LINK: DeviceDirectoryUserLink = 'PRIMARY';
35
+ const SHARED_LINK: DeviceDirectoryUserLink = 'SHARED';
36
+
37
+ /**
38
+ * The USER cell's two lines (Figma 865:55733): the primary user over "Primary user", "Shared" over how many, or the
39
+ * empty mark when nobody is tied to the device.
40
+ */
41
+ export function directoryUserLines(
42
+ device: Pick<DeviceRow, 'directoryUserLink' | 'directoryUserName' | 'directoryUserCount'>,
43
+ ): {
44
+ name: string;
45
+ detail: string | null;
46
+ } {
47
+ if (device.directoryUserLink === PRIMARY_LINK) {
48
+ return { name: device.directoryUserName ?? EMPTY_USER, detail: USER_LINK_LABELS.PRIMARY };
49
+ }
50
+ if (device.directoryUserLink === SHARED_LINK) {
51
+ const count = device.directoryUserCount ?? 0;
52
+ return { name: USER_LINK_LABELS.SHARED, detail: `${count} recent ${count === 1 ? 'user' : 'users'}` };
53
+ }
54
+ return { name: EMPTY_USER, detail: null };
55
+ }
@@ -29,6 +29,8 @@ export interface DevicesFilterToolbarProps {
29
29
  selectedTags: FilterModalProps['selectedTags'];
30
30
  onTagsChange: FilterModalProps['onTagsChange'];
31
31
  isLoading?: boolean;
32
+ /** The search field's prompt. Default "Search for Devices". */
33
+ searchPlaceholder?: string;
32
34
 
33
35
  /**
34
36
  * When true (default), pins the toolbar at the top with the same negative-margin
@@ -67,6 +69,7 @@ export function DevicesFilterToolbar({
67
69
  selectedTags,
68
70
  onTagsChange,
69
71
  isLoading,
72
+ searchPlaceholder = 'Search for Devices',
70
73
  sticky = true,
71
74
  toolbarRef,
72
75
  }: DevicesFilterToolbarProps) {
@@ -87,7 +90,7 @@ export function DevicesFilterToolbar({
87
90
  onSearchChange={onSearchChange}
88
91
  onTagRemove={onTagRemove}
89
92
  onClearAll={onClearAll}
90
- placeholder="Search for Devices"
93
+ placeholder={searchPlaceholder}
91
94
  addMorePlaceholder="Add More..."
92
95
  />
93
96
  </div>
@@ -99,6 +99,8 @@ export interface DevicesPanelViewProps<T extends DeviceRow = DeviceRow> {
99
99
  hideColumns?: string[];
100
100
  /** Filter keys to drop from the funnels and the grid row; the column itself stays. */
101
101
  hideFilters?: string[];
102
+ /** Adds the USER and SOURCES columns and funnels, for a tenant with directories. Default: neither. */
103
+ directoryColumns?: boolean;
102
104
  /** Message shown when the list is empty. */
103
105
  emptyMessage?: string;
104
106
  /** Drawn instead of the toolbar and the list: the pristine, genuinely empty panel. */
@@ -151,6 +153,7 @@ export function DevicesPanelView<T extends DeviceRow = DeviceRow>({
151
153
  actionsColumn,
152
154
  hideColumns,
153
155
  hideFilters,
156
+ directoryColumns = false,
154
157
  emptyMessage = 'No devices found. Try adjusting your search or filters.',
155
158
  emptyState,
156
159
  noOrganizations = false,
@@ -164,11 +167,17 @@ export function DevicesPanelView<T extends DeviceRow = DeviceRow>({
164
167
  const scoped = useLoadingScope();
165
168
  const loading = scoped || loadingGiven;
166
169
  const gridColumns =
167
- filterColumns ?? getDeviceFilterColumns(deviceFilters).filter(column => !hideFilters?.includes(column.key));
170
+ filterColumns ??
171
+ getDeviceFilterColumns(deviceFilters, { directoryColumns }).filter(column => !hideFilters?.includes(column.key));
168
172
 
169
173
  const list = (
170
174
  <>
171
- <DevicesFilterToolbar {...toolbar} isLoading={loading} />
175
+ <DevicesFilterToolbar
176
+ {...toolbar}
177
+ // `devices(search)` also matches the primary user's name once directories are in play (Figma 865:55733).
178
+ searchPlaceholder={directoryColumns ? 'Search for Devices and Users' : undefined}
179
+ isLoading={loading}
180
+ />
172
181
  {viewMode === 'table' ? (
173
182
  <DevicesTableBody
174
183
  devices={devices}
@@ -187,6 +196,7 @@ export function DevicesPanelView<T extends DeviceRow = DeviceRow>({
187
196
  onColumnFiltersChange={onColumnFiltersChange}
188
197
  actionsColumn={actionsColumn}
189
198
  hideColumns={hideColumns}
199
+ directoryColumns={directoryColumns}
190
200
  disableColumnFilters={hideFilters}
191
201
  totalCount={loading ? undefined : totalCount}
192
202
  footerSlot={
@@ -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
  }