@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.
- package/dist/build/export-map.json +1 -1
- package/dist/{case-study-card-WP5Z234Q.cjs → case-study-card-6VAMNHLU.cjs} +8 -8
- package/dist/{chunk-2W22LY3W.cjs → chunk-2JCA7BRK.cjs} +19 -19
- package/dist/{chunk-53QPG7SJ.cjs → chunk-2MCPK6AX.cjs} +28 -28
- package/dist/{chunk-QOW2GP3V.cjs → chunk-2P72KAG5.cjs} +23 -23
- package/dist/{chunk-NMWLXHFH.cjs → chunk-3GJ75OUL.cjs} +183 -183
- package/dist/{chunk-P63CRPWI.cjs → chunk-4H3J57AN.cjs} +25 -25
- package/dist/{chunk-6GWRPW6B.cjs → chunk-5NK3FUHY.cjs} +4 -4
- package/dist/{chunk-DLTPSP5F.cjs → chunk-5TYG5JNB.cjs} +2 -2
- package/dist/{chunk-EIACEY2L.cjs → chunk-65NMKAPQ.cjs} +12 -12
- package/dist/{chunk-IGVAPIEW.cjs → chunk-6C4OR4MZ.cjs} +2 -2
- package/dist/{chunk-GG466XFQ.cjs → chunk-6DMH4GXN.cjs} +30 -5
- package/dist/{chunk-EF2FZ7GT.cjs → chunk-6VVQJU76.cjs} +54 -54
- package/dist/{chunk-G7GF4PWA.cjs → chunk-77GX573I.cjs} +9 -9
- package/dist/{chunk-7RZHSBNX.cjs → chunk-AOOHJLXS.cjs} +90 -90
- package/dist/{chunk-O3EKOXFZ.cjs → chunk-AYMY2VNB.cjs} +37 -43
- package/dist/{chunk-67CZ2Q5K.cjs → chunk-BSPIGZCC.cjs} +36 -36
- package/dist/{chunk-FW7RKWPY.cjs → chunk-D6GEMVHD.cjs} +3 -3
- package/dist/{chunk-KEV6F3QN.cjs → chunk-DPNFI5XM.cjs} +100 -100
- package/dist/{chunk-6V2M2X3M.cjs → chunk-DTZ6KASI.cjs} +6 -6
- package/dist/{chunk-SSKMGH5O.cjs → chunk-DXVGGWLG.cjs} +10 -10
- package/dist/{chunk-IO6NN3SL.cjs → chunk-EOUEUYCQ.cjs} +54 -54
- package/dist/{chunk-5RMBLOJ5.cjs → chunk-ESGE7CUX.cjs} +14 -14
- package/dist/{chunk-ETZFBB7O.cjs → chunk-EXQSIX2I.cjs} +4 -4
- package/dist/{chunk-IC4IJJB2.cjs → chunk-F33EAPD2.cjs} +124 -124
- package/dist/{chunk-COWMXUHN.cjs → chunk-GK577ZLJ.cjs} +6 -14
- package/dist/{chunk-E4MFQKPC.cjs → chunk-GZYRDLJW.cjs} +103 -103
- package/dist/{chunk-LFEURIZN.cjs → chunk-HML3JUXF.cjs} +324 -322
- package/dist/{chunk-BNHTBLAG.cjs → chunk-J3CFRGZ2.cjs} +17 -17
- package/dist/{chunk-USPUZO53.cjs → chunk-KZAQPRTA.cjs} +9 -11
- package/dist/{chunk-HTYJUMFB.cjs → chunk-LSLRJJFP.cjs} +2 -2
- package/dist/{chunk-TBGQMHCG.cjs → chunk-MINFN3IG.cjs} +8 -8
- package/dist/{chunk-KHOB3TES.cjs → chunk-MX52LZ6M.cjs} +6 -6
- package/dist/{chunk-OH5TXFRR.cjs → chunk-O4CPT3RK.cjs} +7 -7
- package/dist/{chunk-HF3V6WBJ.cjs → chunk-OTHQ2KQW.cjs} +3 -3
- package/dist/{chunk-IUDBG3HJ.cjs → chunk-PCQ2HKU3.cjs} +5 -5
- package/dist/{chunk-BNG6JOGZ.cjs → chunk-QPHXYE5P.cjs} +27 -27
- package/dist/{chunk-UO2EJGRJ.cjs → chunk-QZXCUY36.cjs} +53 -53
- package/dist/{chunk-COJG2ATY.cjs → chunk-RIJU4HZ2.cjs} +51 -55
- package/dist/{chunk-KKFDRCQF.cjs → chunk-RY7INTYU.cjs} +14 -1
- package/dist/{chunk-GI3MMBP3.cjs → chunk-SQ7YHWEP.cjs} +228 -15
- package/dist/{chunk-U767P4JU.cjs → chunk-STV6VJ3U.cjs} +21 -21
- package/dist/chunk-T54I5ZZY.cjs +62 -0
- package/dist/{chunk-UV45EPXN.cjs → chunk-TW5Y4D5I.cjs} +7 -7
- package/dist/{chunk-FX35L4Y6.cjs → chunk-XCQ5I27Z.cjs} +3 -3
- package/dist/{chunk-AZEO7HBB.cjs → chunk-Y44EF4JW.cjs} +2 -2
- package/dist/{chunk-W7OFAROY.cjs → chunk-YSXVRTGT.cjs} +9 -12
- package/dist/{chunk-GI6ECCWB.cjs → chunk-ZUGJLCYP.cjs} +1272 -856
- package/dist/{chunk-KNN2GCMW.cjs → chunk-ZYAVIRQV.cjs} +25 -25
- package/dist/{cloud-tenants-QUL2XPAS.cjs → cloud-tenants-2XFIDDGU.cjs} +28 -29
- package/dist/components/case-studies/index.cjs +44 -45
- package/dist/components/chat/chat-message-row.d.ts +1 -1
- package/dist/components/chat/chat-message-row.d.ts.map +1 -1
- package/dist/components/chat/chat-message-row.js +4 -4
- package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts +7 -13
- package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/use-cover-image-fallback.js +2 -8
- package/dist/components/chat/hooks/use-chat-attachment-image-gallery.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-attachment-image-gallery.js +8 -4
- package/dist/components/chat/index.cjs +22 -23
- package/dist/components/contact/index.cjs +29 -30
- package/dist/components/docs/index.cjs +32 -33
- package/dist/components/embeds/index.cjs +29 -30
- package/dist/components/faq/index.cjs +30 -31
- package/dist/components/features/cloud-tenants/index.d.ts +7 -5
- package/dist/components/features/cloud-tenants/index.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/index.js +8 -1
- package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.d.ts +26 -0
- package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.d.ts.map +1 -0
- package/dist/components/features/cloud-tenants/single-customer-tenants-table-view.js +176 -0
- package/dist/components/features/cloud-tenants/tenant-access-cell.d.ts +4 -2
- package/dist/components/features/cloud-tenants/tenant-access-cell.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/tenant-access-cell.js +3 -2
- package/dist/components/features/cloud-tenants/tenant-customer-cell.d.ts +12 -5
- package/dist/components/features/cloud-tenants/tenant-customer-cell.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/tenant-customer-cell.js +38 -7
- package/dist/components/features/cloud-tenants/tenant-licenses-cell.d.ts +7 -0
- package/dist/components/features/cloud-tenants/tenant-licenses-cell.d.ts.map +1 -0
- package/dist/components/features/cloud-tenants/tenant-licenses-cell.js +17 -0
- package/dist/components/features/cloud-tenants/tenant-presentation.d.ts +19 -0
- package/dist/components/features/cloud-tenants/tenant-presentation.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/tenant-presentation.js +24 -3
- package/dist/components/features/cloud-tenants/tenant-row.d.ts +35 -8
- package/dist/components/features/cloud-tenants/tenant-row.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/tenants-table-columns.d.ts +9 -3
- package/dist/components/features/cloud-tenants/tenants-table-columns.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/tenants-table-columns.js +5 -3
- package/dist/components/features/cloud-tenants/tenants-table-view.d.ts +27 -6
- package/dist/components/features/cloud-tenants/tenants-table-view.d.ts.map +1 -1
- package/dist/components/features/cloud-tenants/tenants-table-view.js +75 -17
- package/dist/components/features/devices/device-directory.d.ts +18 -0
- package/dist/components/features/devices/device-directory.d.ts.map +1 -0
- package/dist/components/features/devices/device-directory.js +35 -0
- package/dist/components/features/devices/devices-filter-toolbar.d.ts +3 -1
- package/dist/components/features/devices/devices-filter-toolbar.d.ts.map +1 -1
- package/dist/components/features/devices/devices-filter-toolbar.js +2 -1
- package/dist/components/features/devices/devices-panel-view.d.ts +3 -1
- package/dist/components/features/devices/devices-panel-view.d.ts.map +1 -1
- package/dist/components/features/devices/devices-panel-view.js +11 -2
- package/dist/components/features/devices/devices-table-columns.d.ts +17 -3
- package/dist/components/features/devices/devices-table-columns.d.ts.map +1 -1
- package/dist/components/features/devices/devices-table-columns.js +80 -10
- package/dist/components/features/devices/index.d.ts +1 -0
- package/dist/components/features/devices/index.d.ts.map +1 -1
- package/dist/components/features/devices/index.js +8 -0
- package/dist/components/features/devices/types.d.ts +14 -0
- package/dist/components/features/devices/types.d.ts.map +1 -1
- package/dist/components/features/devices/use-tag-filter-modal.d.ts +1 -0
- package/dist/components/features/devices/use-tag-filter-modal.d.ts.map +1 -1
- package/dist/components/features/devices/use-tag-filter-modal.js +13 -8
- package/dist/components/features/index.cjs +42 -29
- package/dist/components/help-center-pages/index.cjs +134 -135
- package/dist/components/index.cjs +126 -113
- package/dist/components/meeting-scheduler/index.cjs +102 -103
- package/dist/components/navigation/app-layout.js +2 -2
- package/dist/components/navigation/index.cjs +31 -32
- package/dist/components/onboarding-guides/index.cjs +31 -32
- package/dist/components/product-demo/index.cjs +39 -40
- package/dist/components/related-content/index.cjs +16 -17
- package/dist/components/tickets/index.cjs +35 -36
- package/dist/components/ui/date-picker.d.ts +2 -0
- package/dist/components/ui/date-picker.d.ts.map +1 -1
- package/dist/components/ui/date-picker.js +19 -6
- package/dist/components/ui/entity-image.d.ts +1 -2
- package/dist/components/ui/entity-image.d.ts.map +1 -1
- package/dist/components/ui/entity-image.js +4 -8
- package/dist/components/ui/index.cjs +28 -29
- package/dist/components/ui/markdown/base-components.d.ts +0 -13
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
- package/dist/components/ui/markdown/base-components.js +7 -7
- package/dist/components/ui/square-avatar.d.ts.map +1 -1
- package/dist/components/ui/square-avatar.js +3 -8
- package/dist/{devices-LNB4CYPP.cjs → devices-O3DYGIZB.cjs} +29 -30
- package/dist/embed-shims/index.cjs +3 -2
- package/dist/embed-shims/next-image.cjs +3 -2
- package/dist/embed-shims/next-image.d.ts +2 -0
- package/dist/embed-shims/next-image.d.ts.map +1 -1
- package/dist/embed-shims/next-image.js +14 -4
- package/dist/{embeddable-chat-SJEEJI4N.cjs → embeddable-chat-QD7NFD2H.cjs} +100 -101
- package/dist/hooks/index.cjs +27 -16
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/ui/use-content-breakpoint.d.ts +8 -0
- package/dist/hooks/ui/use-content-breakpoint.d.ts.map +1 -1
- package/dist/hooks/ui/use-content-breakpoint.js +17 -3
- package/dist/hooks/ui/use-media-query.d.ts +1 -0
- package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
- package/dist/hooks/ui/use-media-query.js +3 -1
- package/dist/hooks/use-authed-asset-src.d.ts +77 -2
- package/dist/hooks/use-authed-asset-src.d.ts.map +1 -1
- package/dist/hooks/use-authed-asset-src.js +185 -73
- package/dist/hooks/use-authed-image-src.d.ts +11 -4
- package/dist/hooks/use-authed-image-src.d.ts.map +1 -1
- package/dist/hooks/use-authed-image-src.js +6 -2
- package/dist/hooks/use-image-fallback.d.ts +16 -0
- package/dist/hooks/use-image-fallback.d.ts.map +1 -0
- package/dist/hooks/use-image-fallback.js +14 -0
- package/dist/index.cjs +58 -36
- package/dist/{knowledge-UTWKB6XG.cjs → knowledge-K7AFNZNO.cjs} +29 -30
- package/dist/{logs-PMXIWMBQ.cjs → logs-MZ3BR2FR.cjs} +35 -36
- package/dist/{policies-6DCMFDL7.cjs → policies-XVKQ2W7S.cjs} +32 -33
- package/dist/{remote-session-SG44CNOK.cjs → remote-session-VOZJXDM4.cjs} +13 -14
- package/dist/{scripts-2G7LXW7Z.cjs → scripts-IBPMBS6G.cjs} +35 -36
- package/dist/{software-update-MYLKHDHC.cjs → software-update-PXTANMAO.cjs} +32 -33
- package/dist/{tickets-board-SQY2Y6QV.cjs → tickets-board-POYZS7BW.cjs} +16 -17
- package/dist/utils/app-origins.d.ts +3 -6
- package/dist/utils/app-origins.d.ts.map +1 -1
- package/dist/utils/app-origins.js +8 -1
- package/dist/utils/embed-authed-fetch.d.ts +46 -26
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/embed-authed-fetch.js +24 -8
- package/dist/utils/index.cjs +4 -1
- package/dist/utils/presentation-for.d.ts +6 -0
- package/dist/utils/presentation-for.d.ts.map +1 -0
- package/dist/utils/presentation-for.js +7 -0
- package/dist/{video-file-player-XMOB234V.cjs → video-file-player-RIOT24NT.cjs} +3 -4
- package/package.json +1 -1
- package/src/components/chat/chat-message-row.tsx +8 -7
- package/src/components/chat/entity-cards/use-cover-image-fallback.ts +8 -28
- package/src/components/chat/hooks/use-chat-attachment-image-gallery.tsx +19 -8
- package/src/components/features/cloud-tenants/index.ts +8 -3
- package/src/components/features/cloud-tenants/single-customer-tenants-table-view.tsx +234 -0
- package/src/components/features/cloud-tenants/tenant-access-cell.tsx +12 -5
- package/src/components/features/cloud-tenants/tenant-customer-cell.tsx +48 -12
- package/src/components/features/cloud-tenants/tenant-licenses-cell.tsx +28 -0
- package/src/components/features/cloud-tenants/tenant-presentation.ts +46 -4
- package/src/components/features/cloud-tenants/tenant-row.ts +38 -8
- package/src/components/features/cloud-tenants/tenants-table-columns.ts +8 -5
- package/src/components/features/cloud-tenants/tenants-table-view.tsx +123 -28
- package/src/components/features/devices/device-directory.ts +55 -0
- package/src/components/features/devices/devices-filter-toolbar.tsx +4 -1
- package/src/components/features/devices/devices-panel-view.tsx +12 -2
- package/src/components/features/devices/devices-table-columns.tsx +123 -8
- package/src/components/features/devices/index.ts +7 -0
- package/src/components/features/devices/types.ts +14 -0
- package/src/components/features/devices/use-tag-filter-modal.ts +32 -14
- package/src/components/navigation/app-layout.tsx +4 -4
- package/src/components/ui/date-picker.tsx +21 -3
- package/src/components/ui/entity-image.tsx +4 -12
- package/src/components/ui/markdown/base-components.tsx +15 -20
- package/src/components/ui/square-avatar.tsx +5 -8
- package/src/embed-shims/next-image.tsx +34 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/ui/use-content-breakpoint.ts +21 -2
- package/src/hooks/ui/use-media-query.ts +1 -0
- package/src/hooks/use-authed-asset-src.ts +323 -107
- package/src/hooks/use-authed-image-src.ts +15 -5
- package/src/hooks/use-image-fallback.ts +31 -0
- package/src/utils/app-origins.ts +12 -7
- package/src/utils/embed-authed-fetch.ts +71 -32
- package/src/utils/presentation-for.ts +10 -0
- package/dist/chunk-UAI4VMEF.cjs +0 -46
- package/dist/chunk-YSDB6HGQ.cjs +0 -102
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* while closed.
|
|
12
12
|
*
|
|
13
13
|
* Behavior on click:
|
|
14
|
-
* 1. Filter for `<img>` whose `
|
|
15
|
-
* `attachmentViewUrlPrefix` (so non-chat images don't
|
|
14
|
+
* 1. Filter for `<img>` whose address (`assetSrcOf`) starts with the
|
|
15
|
+
* runtime's `attachmentViewUrlPrefix` (so non-chat images don't
|
|
16
|
+
* intercept).
|
|
16
17
|
* 2. Walk up to the nearest `[data-message-role]` ancestor — the
|
|
17
18
|
* boundary the chat-shell wraps each message bubble with.
|
|
18
19
|
* 3. Collect ALL chat-attachment `<img>`s inside that boundary in
|
|
@@ -51,32 +52,32 @@ export function useChatAttachmentImageGallery() {
|
|
|
51
52
|
const onClick = (ev: Event) => {
|
|
52
53
|
const target = ev.target as HTMLElement | null;
|
|
53
54
|
if (!target) return;
|
|
54
|
-
// Match an <img> whose
|
|
55
|
+
// Match an <img> whose address starts with the proxy URL prefix.
|
|
55
56
|
const img = target.closest('img');
|
|
56
|
-
if (!img || !img.
|
|
57
|
+
if (!img || !assetSrcOf(img).startsWith(absolutize(viewUrlPrefix))) return;
|
|
57
58
|
|
|
58
59
|
// Walk up to the message-bubble boundary.
|
|
59
60
|
const bubble = img.closest('[data-message-role]');
|
|
60
61
|
if (!bubble) {
|
|
61
62
|
// Fallback: open the modal with just the clicked image.
|
|
62
63
|
ev.preventDefault();
|
|
63
|
-
setState({ isOpen: true, images: [img
|
|
64
|
+
setState({ isOpen: true, images: [assetSrcOf(img)], initialIndex: 0 });
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// Collect all chat-attachment images inside this bubble in DOM
|
|
68
|
-
// order. The `closest` lookup matched
|
|
69
|
+
// order. The `closest` lookup matched the clicked one's address already,
|
|
69
70
|
// but we re-check below because `querySelectorAll` returns ALL
|
|
70
71
|
// <img>s in the bubble (avatar images etc.).
|
|
71
72
|
const allImgs = Array.from(bubble.querySelectorAll('img'));
|
|
72
|
-
const matching = allImgs.filter(el => el.
|
|
73
|
+
const matching = allImgs.filter(el => assetSrcOf(el).startsWith(absolutize(viewUrlPrefix)));
|
|
73
74
|
if (matching.length === 0) return;
|
|
74
75
|
|
|
75
76
|
const idx = Math.max(0, matching.indexOf(img));
|
|
76
77
|
ev.preventDefault();
|
|
77
78
|
setState({
|
|
78
79
|
isOpen: true,
|
|
79
|
-
images: matching.map(
|
|
80
|
+
images: matching.map(assetSrcOf),
|
|
80
81
|
initialIndex: idx,
|
|
81
82
|
});
|
|
82
83
|
};
|
|
@@ -112,3 +113,13 @@ function absolutize(prefix: string): string {
|
|
|
112
113
|
if (typeof window === 'undefined') return prefix;
|
|
113
114
|
return `${window.location.origin}${prefix.startsWith('/') ? '' : '/'}${prefix}`;
|
|
114
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The address an `<img>` was asked to draw. Where auth rides in a header, the boxed `Image` draws a blob fetched
|
|
119
|
+
* with it and keeps the address in `data-asset-src`: `.src` would be `blob:…`, matching no prefix, and the gallery
|
|
120
|
+
* would never open in the native shells. Resolved against the page, as `.src` is: on dev-ticket web it is relative.
|
|
121
|
+
*/
|
|
122
|
+
function assetSrcOf(img: HTMLImageElement): string {
|
|
123
|
+
const declared = img.dataset.assetSrc;
|
|
124
|
+
return declared ? new URL(declared, window.location.href).href : img.src;
|
|
125
|
+
}
|
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
export { TenantsTableView } from './tenants-table-view';
|
|
4
|
-
export type { TenantsTableViewProps } from './tenants-table-view';
|
|
3
|
+
export { TENANTS_TABLE_FILTER_COLUMNS, TenantsTableView } from './tenants-table-view';
|
|
4
|
+
export type { TenantsTableFilterColumn, TenantsTableFilters, TenantsTableViewProps } from './tenants-table-view';
|
|
5
5
|
export { TenantCell } from './tenant-cell';
|
|
6
6
|
export type { TenantCellProps } from './tenant-cell';
|
|
7
7
|
export { TenantCustomerCell } from './tenant-customer-cell';
|
|
8
8
|
export type { TenantCustomerCellProps } from './tenant-customer-cell';
|
|
9
|
+
export { TenantLicensesCell } from './tenant-licenses-cell';
|
|
10
|
+
export type { TenantLicensesCellProps } from './tenant-licenses-cell';
|
|
9
11
|
export { TenantAccessCell } from './tenant-access-cell';
|
|
10
12
|
export type { TenantAccessCellProps } from './tenant-access-cell';
|
|
11
13
|
export { TENANT_COLUMNS, TENANTS_TABLE_COLUMNS } from './tenants-table-columns';
|
|
12
14
|
export type { TenantsTableColumn } from './tenants-table-columns';
|
|
13
|
-
export type { TenantRow } from './tenant-row';
|
|
15
|
+
export type { MultiCustomerTenantRow, TenantCustomer, TenantLicenseSummary, TenantRow } from './tenant-row';
|
|
14
16
|
export {
|
|
15
17
|
accessStateTag,
|
|
16
18
|
formatLastRead,
|
|
17
19
|
lastReadAt,
|
|
20
|
+
licenseSummaryLines,
|
|
18
21
|
providerMark,
|
|
19
22
|
TENANT_EMPTY_VALUE,
|
|
23
|
+
unattributedLabel,
|
|
20
24
|
usersCountLabel,
|
|
21
25
|
} from './tenant-presentation';
|
|
22
26
|
export type {
|
|
23
27
|
ProviderLogo,
|
|
24
28
|
ProviderMark,
|
|
29
|
+
TenantLicenseLines,
|
|
25
30
|
TenantStatusTag,
|
|
26
31
|
TenantAccessState,
|
|
27
32
|
TenantProvider,
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Lib main's tenants list, one customer per tenant: `TenantsTableView`'s default variant, the list a host renders while
|
|
4
|
+
// tenant-management-v2 is off. Its markup is lib main's to the class; it is deleted with that variant.
|
|
5
|
+
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
import { cn } from '../../../utils/cn';
|
|
8
|
+
import { openInNewTab } from '../../../utils/open-in-new-tab';
|
|
9
|
+
import { ArrowRightUpIcon } from '../../icons-v2-generated/arrows/arrow-right-up-icon';
|
|
10
|
+
import { Button } from '../../ui/button';
|
|
11
|
+
import { type ColumnDef, DataTable, dataTableLoadingRows, type Row, useDataTable } from '../../ui/data-table';
|
|
12
|
+
import { useLoadingScope } from '../../ui/loading-scope';
|
|
13
|
+
import { SquareAvatar } from '../../ui/square-avatar';
|
|
14
|
+
import { Tag } from '../../ui/tag';
|
|
15
|
+
import { TruncateText } from '../../ui/truncate-text';
|
|
16
|
+
import { TenantCell } from './tenant-cell';
|
|
17
|
+
import { accessStateTag, formatLastRead, usersCountLabel } from './tenant-presentation';
|
|
18
|
+
import type { TenantRow } from './tenant-row';
|
|
19
|
+
import type { TenantsTableColumn } from './tenants-table-columns';
|
|
20
|
+
|
|
21
|
+
/** Column layout of the single-customer list (Figma 1699-8249: Tenant, Customers, Access, open). */
|
|
22
|
+
const SINGLE_CUSTOMER_TENANT_COLUMNS = {
|
|
23
|
+
tenant: { id: 'tenant', header: 'Tenant', width: 'flex-1 min-w-0' },
|
|
24
|
+
customer: { id: 'customer', header: 'Customers', width: 'w-[220px] content-lg:w-[340px]', hideAt: 'md' },
|
|
25
|
+
access: { id: 'access', header: 'Access', width: 'w-[200px] content-lg:w-[216px]' },
|
|
26
|
+
open: { id: 'open', width: 'w-12 shrink-0 flex-none', hideAt: 'md', align: 'right' },
|
|
27
|
+
} satisfies Record<string, TenantsTableColumn>;
|
|
28
|
+
|
|
29
|
+
const getRowId = (row: TenantRow) => row.id;
|
|
30
|
+
|
|
31
|
+
const columnMeta = ({ width, hideAt, align }: TenantsTableColumn) => ({ width, hideAt, align });
|
|
32
|
+
|
|
33
|
+
/** A tenant still being read: a row the table keys, every field a cell shows empty (each draws its loading form). */
|
|
34
|
+
const loadingTenant = (id: string): TenantRow => ({
|
|
35
|
+
id,
|
|
36
|
+
name: '',
|
|
37
|
+
provider: '',
|
|
38
|
+
customer: { name: '' },
|
|
39
|
+
accessState: '',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** CUSTOMERS column: customer logo + name over the synced user count. */
|
|
43
|
+
function SingleCustomerTenantCustomerCell({
|
|
44
|
+
name,
|
|
45
|
+
imageUrl,
|
|
46
|
+
userCount,
|
|
47
|
+
}: {
|
|
48
|
+
name: string;
|
|
49
|
+
imageUrl?: string | null;
|
|
50
|
+
userCount?: number | null;
|
|
51
|
+
}) {
|
|
52
|
+
// While the tenant is read (a loading scope) the user count is a bar too (its label is never empty).
|
|
53
|
+
const loading = useLoadingScope();
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex min-w-0 flex-col justify-center">
|
|
56
|
+
<div className="flex min-w-0 items-center gap-[var(--spacing-system-xxs)]">
|
|
57
|
+
<SquareAvatar src={imageUrl ?? undefined} alt={name} fallback={name} size="xs" variant="square" />
|
|
58
|
+
<TruncateText>{name}</TruncateText>
|
|
59
|
+
</div>
|
|
60
|
+
<TruncateText variant="h6" tone="secondary" loading={loading}>
|
|
61
|
+
{usersCountLabel(userCount)}
|
|
62
|
+
</TruncateText>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ACCESS column: the access tag over "Last read: ...". */
|
|
68
|
+
function SingleCustomerTenantAccessCell({
|
|
69
|
+
accessState,
|
|
70
|
+
lastReadAt,
|
|
71
|
+
now,
|
|
72
|
+
}: {
|
|
73
|
+
accessState: string;
|
|
74
|
+
lastReadAt?: string | null;
|
|
75
|
+
now?: Date;
|
|
76
|
+
}) {
|
|
77
|
+
// While the tenant is read (a loading scope) the access tag and its last read are their loading forms.
|
|
78
|
+
const loading = useLoadingScope();
|
|
79
|
+
return (
|
|
80
|
+
<div className="flex min-w-0 flex-col justify-center gap-[var(--spacing-system-xxs)]">
|
|
81
|
+
<Tag {...accessStateTag(accessState)} loading={loading} className="self-start" />
|
|
82
|
+
<TruncateText variant="h6" tone="secondary" loading={loading}>
|
|
83
|
+
{`Last read: ${formatLastRead(lastReadAt, now)}`}
|
|
84
|
+
</TruncateText>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The row's open-in-new-tab button: static chrome, drawn real and inert while the row is read. */
|
|
90
|
+
function OpenTenantButton({ href, name }: { href: string; name: string }) {
|
|
91
|
+
const loading = useLoadingScope();
|
|
92
|
+
return (
|
|
93
|
+
// The row itself is the details link; a nested `<a>` is invalid, so this opens the same href.
|
|
94
|
+
<div data-no-row-click className="pointer-events-auto flex items-center justify-end">
|
|
95
|
+
<Button
|
|
96
|
+
onClick={loading ? undefined : openInNewTab(href)}
|
|
97
|
+
disabled={loading}
|
|
98
|
+
variant="outline"
|
|
99
|
+
size="icon"
|
|
100
|
+
leftIcon={<ArrowRightUpIcon className="h-5 w-5" />}
|
|
101
|
+
aria-label={`Open ${name} in new tab`}
|
|
102
|
+
className="bg-ods-card"
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SingleCustomerTenantsTableViewProps {
|
|
109
|
+
/** The default: one customer per tenant, no licences and no funnels. */
|
|
110
|
+
variant?: 'single-customer';
|
|
111
|
+
rows: TenantRow[];
|
|
112
|
+
/** Where a row leads: the row is a link to it, and its last cell opens it in a new tab. */
|
|
113
|
+
getHref: (row: TenantRow) => string;
|
|
114
|
+
/** The search the rows answer; named by the "no match" message. */
|
|
115
|
+
search?: string;
|
|
116
|
+
/** Funnels belong to the multi-customer list. */
|
|
117
|
+
filters?: never;
|
|
118
|
+
/** The rows on screen are the previous result of a search still loading: they dim. */
|
|
119
|
+
isPending?: boolean;
|
|
120
|
+
/** All matching tenants, loaded or not. Default: the rows given. */
|
|
121
|
+
totalCount?: number;
|
|
122
|
+
stickyHeaderOffset?: string;
|
|
123
|
+
skeletonRows?: number;
|
|
124
|
+
hasNextPage?: boolean;
|
|
125
|
+
isFetchingNextPage?: boolean;
|
|
126
|
+
onLoadMore?: () => void;
|
|
127
|
+
/** The clock "Last read" is relative to. Default: the moment of the render. */
|
|
128
|
+
now?: Date;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The rows a loading tenants table draws when the host states none (a typical page of tenants). */
|
|
132
|
+
const TENANTS_TABLE_SKELETON_ROWS = 20;
|
|
133
|
+
|
|
134
|
+
/** The tenants list: tenant, customer, access state and a way into each tenant's details. */
|
|
135
|
+
export function SingleCustomerTenantsTableView({
|
|
136
|
+
rows,
|
|
137
|
+
getHref,
|
|
138
|
+
search = '',
|
|
139
|
+
isPending = false,
|
|
140
|
+
totalCount,
|
|
141
|
+
stickyHeaderOffset,
|
|
142
|
+
skeletonRows = TENANTS_TABLE_SKELETON_ROWS,
|
|
143
|
+
hasNextPage = false,
|
|
144
|
+
isFetchingNextPage = false,
|
|
145
|
+
onLoadMore,
|
|
146
|
+
now,
|
|
147
|
+
}: SingleCustomerTenantsTableViewProps) {
|
|
148
|
+
const columns = useMemo<ColumnDef<TenantRow>[]>(
|
|
149
|
+
() => [
|
|
150
|
+
{
|
|
151
|
+
id: SINGLE_CUSTOMER_TENANT_COLUMNS.tenant.id,
|
|
152
|
+
header: SINGLE_CUSTOMER_TENANT_COLUMNS.tenant.header,
|
|
153
|
+
cell: ({ row }: { row: Row<TenantRow> }) => (
|
|
154
|
+
<TenantCell provider={row.original.provider} name={row.original.name} domain={row.original.domain} />
|
|
155
|
+
),
|
|
156
|
+
enableSorting: false,
|
|
157
|
+
meta: columnMeta(SINGLE_CUSTOMER_TENANT_COLUMNS.tenant),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: SINGLE_CUSTOMER_TENANT_COLUMNS.customer.id,
|
|
161
|
+
header: SINGLE_CUSTOMER_TENANT_COLUMNS.customer.header,
|
|
162
|
+
cell: ({ row }: { row: Row<TenantRow> }) => (
|
|
163
|
+
<SingleCustomerTenantCustomerCell
|
|
164
|
+
name={row.original.customer.name}
|
|
165
|
+
imageUrl={row.original.customer.imageUrl}
|
|
166
|
+
userCount={row.original.userCount}
|
|
167
|
+
/>
|
|
168
|
+
),
|
|
169
|
+
enableSorting: false,
|
|
170
|
+
meta: columnMeta(SINGLE_CUSTOMER_TENANT_COLUMNS.customer),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: SINGLE_CUSTOMER_TENANT_COLUMNS.access.id,
|
|
174
|
+
header: SINGLE_CUSTOMER_TENANT_COLUMNS.access.header,
|
|
175
|
+
cell: ({ row }: { row: Row<TenantRow> }) => (
|
|
176
|
+
<SingleCustomerTenantAccessCell
|
|
177
|
+
accessState={row.original.accessState}
|
|
178
|
+
lastReadAt={row.original.lastReadAt}
|
|
179
|
+
now={now}
|
|
180
|
+
/>
|
|
181
|
+
),
|
|
182
|
+
enableSorting: false,
|
|
183
|
+
meta: columnMeta(SINGLE_CUSTOMER_TENANT_COLUMNS.access),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: SINGLE_CUSTOMER_TENANT_COLUMNS.open.id,
|
|
187
|
+
cell: ({ row }: { row: Row<TenantRow> }) => (
|
|
188
|
+
<OpenTenantButton href={getHref(row.original)} name={row.original.name} />
|
|
189
|
+
),
|
|
190
|
+
enableSorting: false,
|
|
191
|
+
meta: columnMeta(SINGLE_CUSTOMER_TENANT_COLUMNS.open),
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
[getHref, now],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// A page's loading scope: the table holds one page of placeholder rows, drawn with the real cells (`loadingCells`).
|
|
198
|
+
const loading = useLoadingScope();
|
|
199
|
+
const loadingRows = useMemo(
|
|
200
|
+
() => (loading ? dataTableLoadingRows(skeletonRows, loadingTenant) : null),
|
|
201
|
+
[loading, skeletonRows],
|
|
202
|
+
);
|
|
203
|
+
const table = useDataTable<TenantRow>({ data: loadingRows ?? rows, columns, getRowId, enableSorting: false });
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
// Dim, don't unmount, the stale rows while a deferred search refetches.
|
|
207
|
+
<div className={cn('transition-opacity duration-200', isPending && 'opacity-60')}>
|
|
208
|
+
<DataTable table={table}>
|
|
209
|
+
<DataTable.Header
|
|
210
|
+
stickyHeader={stickyHeaderOffset !== undefined}
|
|
211
|
+
stickyHeaderOffset={stickyHeaderOffset}
|
|
212
|
+
rightSlot={<DataTable.RowCount itemName="result" totalCount={totalCount ?? rows.length} loading={loading} />}
|
|
213
|
+
/>
|
|
214
|
+
<DataTable.Body
|
|
215
|
+
loadingCells
|
|
216
|
+
skeletonRows={skeletonRows}
|
|
217
|
+
emptyState={{
|
|
218
|
+
title: 'No tenants match',
|
|
219
|
+
description: `Nothing matches "${search}". Try a different name, domain or customer.`,
|
|
220
|
+
}}
|
|
221
|
+
rowClassName="mb-[var(--spacing-system-xxs)]"
|
|
222
|
+
rowHref={getHref}
|
|
223
|
+
/>
|
|
224
|
+
{rows.length > 0 && onLoadMore && (
|
|
225
|
+
<DataTable.InfiniteFooter
|
|
226
|
+
hasNextPage={hasNextPage}
|
|
227
|
+
isFetchingNextPage={isFetchingNextPage}
|
|
228
|
+
onLoadMore={onLoadMore}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
</DataTable>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -8,20 +8,27 @@ import { accessStateTag, formatLastRead } from './tenant-presentation';
|
|
|
8
8
|
export interface TenantAccessCellProps {
|
|
9
9
|
accessState: string;
|
|
10
10
|
lastReadAt?: string | null;
|
|
11
|
+
/** A person paused the sync: said instead of the last read. */
|
|
12
|
+
syncPaused?: boolean;
|
|
11
13
|
/** The clock "Last read" is relative to. Default: the moment of the render. */
|
|
12
14
|
now?: Date;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
/** ACCESS column: the access tag over "Last read: ...". */
|
|
16
|
-
export function TenantAccessCell({ accessState, lastReadAt, now }: TenantAccessCellProps) {
|
|
17
|
+
/** ACCESS column: the access tag over "Last read: ..." or "Sync paused"; a phone keeps the tag alone (Figma 3033-9343). */
|
|
18
|
+
export function TenantAccessCell({ accessState, lastReadAt, syncPaused = false, now }: TenantAccessCellProps) {
|
|
17
19
|
// While the tenant is read (a loading scope) the access tag and its last read are their loading forms.
|
|
18
20
|
const loading = useLoadingScope();
|
|
21
|
+
const line = syncPaused ? 'Sync paused' : lastReadAt ? `Last read: ${formatLastRead(lastReadAt, now)}` : null;
|
|
19
22
|
return (
|
|
20
23
|
<div className="flex min-w-0 flex-col justify-center gap-[var(--spacing-system-xxs)]">
|
|
21
24
|
<Tag {...accessStateTag(accessState)} loading={loading} className="self-start" />
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
{(loading || line) && (
|
|
26
|
+
<div className="hidden min-w-0 content-md:block">
|
|
27
|
+
<TruncateText variant="h6" tone="secondary" loading={loading}>
|
|
28
|
+
{line ?? ''}
|
|
29
|
+
</TruncateText>
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
25
32
|
</div>
|
|
26
33
|
);
|
|
27
34
|
}
|
|
@@ -3,27 +3,63 @@
|
|
|
3
3
|
import { useLoadingScope } from '../../ui/loading-scope';
|
|
4
4
|
import { SquareAvatar } from '../../ui/square-avatar';
|
|
5
5
|
import { TruncateText } from '../../ui/truncate-text';
|
|
6
|
-
import {
|
|
6
|
+
import { lowercaseUsersCountLabel, TENANT_EMPTY_VALUE, unattributedLabel } from './tenant-presentation';
|
|
7
|
+
import type { TenantCustomer } from './tenant-row';
|
|
7
8
|
|
|
8
9
|
export interface TenantCustomerCellProps {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
imageUrl?: string | null;
|
|
10
|
+
/** In the host's order: the cell names the first and counts the rest. */
|
|
11
|
+
customers: readonly TenantCustomer[];
|
|
12
12
|
userCount?: number | null;
|
|
13
|
+
/** Users no customer's rule claims: while there are any, the count line says how many, in the warning colour. */
|
|
14
|
+
unattributedUserCount?: number | null;
|
|
15
|
+
/** Nothing has been read from this directory yet: the count line says so. */
|
|
16
|
+
neverRead?: boolean;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* CUSTOMERS column: the first customer's logo + name and "+N" for the rest, over the synced user count — or
|
|
21
|
+
* "30 of 412 unattributed" while some users belong to no customer, or "Never read" before the first read.
|
|
22
|
+
*/
|
|
23
|
+
export function TenantCustomerCell({
|
|
24
|
+
customers,
|
|
25
|
+
userCount,
|
|
26
|
+
unattributedUserCount,
|
|
27
|
+
neverRead = false,
|
|
28
|
+
}: TenantCustomerCellProps) {
|
|
17
29
|
// While the tenant is read (a loading scope) the user count is a bar too (its label is never empty).
|
|
18
30
|
const loading = useLoadingScope();
|
|
31
|
+
const [first] = customers;
|
|
32
|
+
const more = customers.length - 1;
|
|
33
|
+
const unattributed = unattributedLabel(unattributedUserCount, userCount);
|
|
19
34
|
return (
|
|
20
35
|
<div className="flex min-w-0 flex-col justify-center">
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
{first ? (
|
|
37
|
+
<div className="flex min-w-0 items-center gap-[var(--spacing-system-xxs)]">
|
|
38
|
+
<SquareAvatar
|
|
39
|
+
src={first.imageUrl ?? undefined}
|
|
40
|
+
alt={first.name}
|
|
41
|
+
fallback={first.name}
|
|
42
|
+
size="xs"
|
|
43
|
+
variant="square"
|
|
44
|
+
/>
|
|
45
|
+
<TruncateText>{first.name}</TruncateText>
|
|
46
|
+
{more > 0 && (
|
|
47
|
+
<span className="shrink-0 text-ods-text-secondary text-h4">
|
|
48
|
+
+{more}
|
|
49
|
+
<span className="sr-only">{more === 1 ? ' more customer' : ' more customers'}</span>
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
) : (
|
|
54
|
+
<TruncateText>{TENANT_EMPTY_VALUE}</TruncateText>
|
|
55
|
+
)}
|
|
56
|
+
<TruncateText
|
|
57
|
+
variant="h6"
|
|
58
|
+
tone="secondary"
|
|
59
|
+
loading={loading}
|
|
60
|
+
className={unattributed ? 'text-ods-warning' : undefined}
|
|
61
|
+
>
|
|
62
|
+
{unattributed ?? (neverRead ? 'Never read' : lowercaseUsersCountLabel(userCount))}
|
|
27
63
|
</TruncateText>
|
|
28
64
|
</div>
|
|
29
65
|
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../utils/cn';
|
|
4
|
+
import { useLoadingScope } from '../../ui/loading-scope';
|
|
5
|
+
import { TruncateText } from '../../ui/truncate-text';
|
|
6
|
+
import { licenseSummaryLines } from './tenant-presentation';
|
|
7
|
+
import type { TenantLicenseSummary } from './tenant-row';
|
|
8
|
+
|
|
9
|
+
export interface TenantLicensesCellProps {
|
|
10
|
+
licenses?: TenantLicenseSummary | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** LICENCES column: assigned of purchased over what is unused (Figma 3025-3536). */
|
|
14
|
+
export function TenantLicensesCell({ licenses }: TenantLicensesCellProps) {
|
|
15
|
+
// While the tenant is read (a loading scope) both lines are bars.
|
|
16
|
+
const loading = useLoadingScope();
|
|
17
|
+
const { value, detail, warning } = licenseSummaryLines(licenses);
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex min-w-0 flex-col justify-center">
|
|
20
|
+
<TruncateText loading={loading}>{value}</TruncateText>
|
|
21
|
+
{(loading || detail) && (
|
|
22
|
+
<TruncateText variant="h6" tone="secondary" loading={loading} className={cn(warning && 'text-ods-warning')}>
|
|
23
|
+
{detail ?? ''}
|
|
24
|
+
</TruncateText>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
import type { ComponentType, SVGProps } from 'react';
|
|
12
12
|
import { formatRelativeTimeFrom } from '../../../utils/date-utils';
|
|
13
13
|
import { formatDate } from '../../../utils/format-date';
|
|
14
|
+
import { presentationFor } from '../../../utils/presentation-for';
|
|
14
15
|
import { GoogleLogoIcon } from '../../icons-v2-generated/brand-logos/google-logo-icon';
|
|
15
16
|
import { Office365LogoIcon } from '../../icons-v2-generated/brand-logos/office-365-logo-icon';
|
|
16
17
|
import { CodingForkIcon } from '../../icons-v2-generated/coding/coding-fork-icon';
|
|
17
18
|
import type { TagProps } from '../../ui/tag';
|
|
19
|
+
import type { TenantLicenseSummary } from './tenant-row';
|
|
18
20
|
|
|
19
21
|
/** What a cell shows when it has no value. */
|
|
20
22
|
export const TENANT_EMPTY_VALUE = '—';
|
|
@@ -33,10 +35,6 @@ export type TenantAccessState =
|
|
|
33
35
|
/** The directory providers a tenant connects to. */
|
|
34
36
|
export type TenantProvider = 'MICROSOFT_365' | 'GOOGLE_WORKSPACE';
|
|
35
37
|
|
|
36
|
-
function presentationFor<K extends string, V>(map: Record<K, V>, key: string | null | undefined): V | undefined {
|
|
37
|
-
return key != null && key in map ? map[key as K] : undefined;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
38
|
// Colours read off the Figma list (node 1699-8249): red for the two states that
|
|
41
39
|
// need the customer's admin, grey for read-only, outline for write-available,
|
|
42
40
|
// green for write-enabled. NOT_AUTHORISED has no frame of its own
|
|
@@ -109,3 +107,47 @@ export function usersCountLabel(count: number | null | undefined): string {
|
|
|
109
107
|
if (count == null) return TENANT_EMPTY_VALUE;
|
|
110
108
|
return `${count} ${count === 1 ? 'User' : 'Users'}`;
|
|
111
109
|
}
|
|
110
|
+
|
|
111
|
+
/** The multi-customer list's count line (Figma 3025-3536): "227 users", the empty mark before the first read. */
|
|
112
|
+
export function lowercaseUsersCountLabel(count: number | null | undefined): string {
|
|
113
|
+
if (count == null) return TENANT_EMPTY_VALUE;
|
|
114
|
+
return `${count} ${count === 1 ? 'user' : 'users'}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* "30 of 412 unattributed" while some synced users belong to no customer (no customer's rule claims them), or `null`
|
|
119
|
+
* while every user is attributed or nothing has been read.
|
|
120
|
+
*/
|
|
121
|
+
export function unattributedLabel(
|
|
122
|
+
unattributed: number | null | undefined,
|
|
123
|
+
users: number | null | undefined,
|
|
124
|
+
): string | null {
|
|
125
|
+
if (unattributed == null || unattributed <= 0) return null;
|
|
126
|
+
return users == null ? `${unattributed} unattributed` : `${unattributed} of ${users} unattributed`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The share of purchased seats left unused that the list calls out: every row of Figma 3025-3536 falls on its side. */
|
|
130
|
+
const UNUSED_WARNING_SHARE = 0.1;
|
|
131
|
+
|
|
132
|
+
export interface TenantLicenseLines {
|
|
133
|
+
value: string;
|
|
134
|
+
detail: string | null;
|
|
135
|
+
/** The detail is worth a look: draw it in the warning colour. */
|
|
136
|
+
warning: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The LICENCES cell: "118 of 120" over "2 unused"; "88 assigned" over "Total not available" where the provider reports
|
|
141
|
+
* no purchased seats; the empty mark until the numbers have been read.
|
|
142
|
+
*/
|
|
143
|
+
export function licenseSummaryLines(summary: TenantLicenseSummary | null | undefined): TenantLicenseLines {
|
|
144
|
+
if (!summary) return { value: TENANT_EMPTY_VALUE, detail: null, warning: false };
|
|
145
|
+
const { assigned, purchased } = summary;
|
|
146
|
+
if (purchased == null) return { value: `${assigned} assigned`, detail: 'Total not available', warning: false };
|
|
147
|
+
const unused = summary.unused ?? purchased - assigned;
|
|
148
|
+
return {
|
|
149
|
+
value: `${assigned} of ${purchased}`,
|
|
150
|
+
detail: unused < 0 ? `${-unused} over` : `${unused} unused`,
|
|
151
|
+
warning: unused > 0 && unused / purchased >= UNUSED_WARNING_SHARE,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export interface
|
|
1
|
+
/** A customer a tenant's directory serves. */
|
|
2
|
+
export interface TenantCustomer {
|
|
3
|
+
name: string;
|
|
4
|
+
/** A URL the browser can load as is. */
|
|
5
|
+
imageUrl?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** A directory's seats over its paid SKUs. */
|
|
9
|
+
export interface TenantLicenseSummary {
|
|
10
|
+
assigned: number;
|
|
11
|
+
/** Absent when the provider does not report purchased seats (Google Workspace). */
|
|
12
|
+
purchased?: number | null;
|
|
13
|
+
/** Purchased minus assigned; negative when the provider counts suspended seats as assigned. */
|
|
14
|
+
unused?: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TenantRowBase {
|
|
3
18
|
id: string;
|
|
4
19
|
/** The tenant's own name in its directory. */
|
|
5
20
|
name: string;
|
|
6
21
|
/** The directory provider (`MICROSOFT_365`, `GOOGLE_WORKSPACE`); an unknown value keeps its raw name. */
|
|
7
22
|
provider: string;
|
|
8
23
|
domain?: string | null;
|
|
9
|
-
/** The customer the tenant belongs to. */
|
|
10
|
-
customer: {
|
|
11
|
-
name: string;
|
|
12
|
-
/** A URL the browser can load as is. */
|
|
13
|
-
imageUrl?: string | null;
|
|
14
|
-
};
|
|
15
24
|
/** Users read from the directory; absent before the first read. */
|
|
16
25
|
userCount?: number | null;
|
|
17
26
|
/** What the last access probe found (`WRITE_ENABLED`, `DISCONNECTED`, ...). */
|
|
@@ -19,3 +28,24 @@ export interface TenantRow {
|
|
|
19
28
|
/** When the directory was last read; absent when it never was. */
|
|
20
29
|
lastReadAt?: string | null;
|
|
21
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A tenant of the single-customer list, `TenantsTableView`'s default variant, which accepts no other row: a
|
|
34
|
+
* multi-customer host that forgets `variant` does not compile. Deleted with that variant.
|
|
35
|
+
*/
|
|
36
|
+
export interface TenantRow extends TenantRowBase {
|
|
37
|
+
/** The customer the tenant belongs to. */
|
|
38
|
+
customer: TenantCustomer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A tenant of the multi-customer list (`variant="multi-customer"`): a directory serving any number of customers. */
|
|
42
|
+
export interface MultiCustomerTenantRow extends TenantRowBase {
|
|
43
|
+
/** The customers the directory serves, in the order the host shows them; empty while it serves none. */
|
|
44
|
+
customers: readonly TenantCustomer[];
|
|
45
|
+
/** Of the users read, the users no customer's rule claims; absent before the first read. */
|
|
46
|
+
unattributedUserCount?: number | null;
|
|
47
|
+
/** A person paused the sync: the row says so instead of when it was last read. */
|
|
48
|
+
syncPaused?: boolean;
|
|
49
|
+
/** Absent until the licence numbers have been read. */
|
|
50
|
+
licenses?: TenantLicenseSummary | null;
|
|
51
|
+
}
|
|
@@ -9,22 +9,25 @@ export interface TenantsTableColumn {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Column layout of the tenants list (Figma
|
|
13
|
-
*
|
|
12
|
+
* Column layout of the tenants list (Figma 3025-3536: Tenant, Customers, Licences, Access, open). A tablet drops the
|
|
13
|
+
* licence cells and keeps the funnel (3032-4579); a phone keeps the tenant, the access tag and open (3033-9343).
|
|
14
|
+
* Data-only on purpose: the live table and the route skeleton
|
|
14
15
|
* read the SAME widths, so nothing shifts when the rows arrive, and a skeleton
|
|
15
16
|
* never imports the table's cell renderers.
|
|
16
17
|
*/
|
|
17
18
|
export const TENANT_COLUMNS = {
|
|
18
19
|
tenant: { id: 'tenant', header: 'Tenant', width: 'flex-1 min-w-0' },
|
|
19
|
-
customer: { id: 'customer', header: 'Customers', width: 'w-[220px] content-lg:w-[
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
customer: { id: 'customer', header: 'Customers', width: 'w-[220px] content-lg:w-[280px]', hideAt: 'md' },
|
|
21
|
+
licenses: { id: 'licenses', header: 'Licences', width: 'w-[150px]', hideAt: 'lg' },
|
|
22
|
+
access: { id: 'access', header: 'Access', width: 'flex-none content-md:w-[200px] content-lg:w-[216px]' },
|
|
23
|
+
open: { id: 'open', width: 'w-12 shrink-0 flex-none', align: 'right' },
|
|
22
24
|
} satisfies Record<string, TenantsTableColumn>;
|
|
23
25
|
|
|
24
26
|
/** Render order of the standalone list. */
|
|
25
27
|
export const TENANTS_TABLE_COLUMNS: readonly TenantsTableColumn[] = [
|
|
26
28
|
TENANT_COLUMNS.tenant,
|
|
27
29
|
TENANT_COLUMNS.customer,
|
|
30
|
+
TENANT_COLUMNS.licenses,
|
|
28
31
|
TENANT_COLUMNS.access,
|
|
29
32
|
TENANT_COLUMNS.open,
|
|
30
33
|
];
|