Skip to content

Commit 5bb5182

Browse files
authored
feat: UTC-402: Organization Members v2 (#9037)
1 parent 818dd4a commit 5bb5182

21 files changed

+2506
-26
lines changed

web/libs/ui/src/assets/icons/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export { ReactComponent as IconPause } from "./pause.svg";
195195
export { ReactComponent as IconPencil } from "./pencil.svg";
196196
export { ReactComponent as IconPeople } from "./people.svg";
197197
export { ReactComponent as IconPersonInCircle } from "./person-circle.svg";
198+
export { ReactComponent as IconPhone } from "./phone.svg";
198199
export { ReactComponent as IconPin } from "./pin.svg";
199200
export { ReactComponent as IconUprightPin } from "./upright-pin.svg";
200201
export { ReactComponent as IconPlay } from "./play.svg";
@@ -232,6 +233,7 @@ export { ReactComponent as IconSend } from "./send.svg";
232233
export { ReactComponent as IconSettings } from "./settings.svg";
233234
export { ReactComponent as IconSlack } from "./slack.svg";
234235
export { ReactComponent as IconSlow } from "./slow.svg";
236+
export { ReactComponent as IconSmartphone } from "./smartphone.svg";
235237
export { ReactComponent as IconSortDown } from "./sort-down.svg";
236238
export { ReactComponent as IconSortUp } from "./sort-up.svg";
237239
export { ReactComponent as IconSoundBars } from "./sound-bars.svg";
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

web/libs/ui/src/lib/data-table/data-table.stories.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
22
import { useState } from "react";
33
import { DataTable } from "./data-table";
44
import type { ColumnDef, SortingState } from "@tanstack/react-table";
5+
import type { ExtendedDataTableColumnDef } from "./data-table";
56
import { Badge } from "../badge/badge";
67
import { Button } from "../button/button";
78
import { IconEdit, IconTrash } from "@humansignal/icons";
@@ -146,6 +147,45 @@ export const WithSorting: Story = {
146147
},
147148
};
148149

150+
/**
151+
* Table with Help Tooltips
152+
*
153+
* Demonstrates the `help` property on columns, which displays an info icon with a tooltip
154+
* in the column header. Hover over the info icon next to "Last Active" to see the tooltip.
155+
*/
156+
export const WithHelpTooltips: Story = {
157+
render: () => {
158+
const [sorting, setSorting] = useState<SortingState>([]);
159+
160+
const columnsWithHelp: ExtendedDataTableColumnDef<User>[] = [
161+
...baseColumns.slice(0, -1), // All columns except lastActive
162+
{
163+
accessorKey: "lastActive",
164+
header: "Last Active",
165+
enableSorting: true,
166+
help: "The date when the user was last active in the system. This includes any activity such as logging in, viewing content, or making changes.",
167+
},
168+
];
169+
170+
return (
171+
<div className="flex flex-col gap-4">
172+
<div className="p-4 bg-neutral-surface rounded-md">
173+
<p className="text-sm text-neutral-content-subtle">
174+
💡 Hover over the info icon next to "Last Active" header to see the help tooltip
175+
</p>
176+
</div>
177+
<DataTable
178+
data={sampleData}
179+
columns={columnsWithHelp}
180+
enableSorting
181+
sorting={sorting}
182+
onSortingChange={setSorting}
183+
/>
184+
</div>
185+
);
186+
},
187+
};
188+
149189
export const WithSelection: Story = {
150190
render: () => {
151191
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});

web/libs/ui/src/lib/data-table/data-table.tsx

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,36 @@ import {
1212
type SortingState,
1313
} from "@tanstack/react-table";
1414

15-
// Extend ColumnMeta to include noDivider and sortParam
15+
// Extend ColumnMeta to include noDivider
1616
declare module "@tanstack/react-table" {
1717
interface ColumnMeta<TData, TValue> {
1818
noDivider?: boolean;
19-
sortParam?: string; // API field name for sorting (e.g., "user__first_name")
2019
}
2120
}
2221
import { memo, useState, useMemo, useCallback } from "react";
2322
import { cn } from "../../utils/utils";
2423
import { useColumnSizing, useDataColumns } from "../../hooks/data-table";
2524
import { Checkbox } from "../checkbox/checkbox";
2625
import { Typography } from "../typography/typography";
27-
import { IconSortUp, IconSortDown, IconSearch } from "@humansignal/icons";
26+
import { Tooltip } from "../Tooltip/Tooltip";
27+
import { IconSortUp, IconSortDown, IconSearch, IconInfoOutline } from "@humansignal/icons";
2828
import { EmptyState } from "../empty-state/empty-state";
2929
import { Skeleton } from "../skeleton/skeleton";
3030
import styles from "./data-table.module.scss";
3131

3232
export type DataShape = Record<string, any>[];
3333

34+
/**
35+
* Extended ColumnDef type that includes custom properties for generic DataTable
36+
*/
37+
export type ExtendedDataTableColumnDef<T> = ColumnDef<T> & {
38+
help?: string; // Optional help text to display in a tooltip with info icon
39+
};
40+
3441
export type DataTableProps<T extends DataShape> = {
3542
data: T;
3643
meta?: TableMeta<any>;
37-
columns?: ColumnDef<T[number]>[];
44+
columns?: ExtendedDataTableColumnDef<T[number]>[];
3845
extraColumns?: ColumnDef<any>[];
3946
includeColumns?: (keyof T[number])[];
4047
excludeColumns?: (keyof T[number])[];
@@ -111,6 +118,33 @@ export const DataTable = <T extends DataShape>(props: DataTableProps<T>) => {
111118
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
112119
const [internalActiveRowId, setInternalActiveRowId] = useState<string | undefined>(undefined);
113120

121+
// Restore column sizes from localStorage if storageKey is provided
122+
const restoredColumnSizing = useMemo(() => {
123+
if (!props.cellSizesStorageKey) return {};
124+
125+
try {
126+
const stored = localStorage.getItem(props.cellSizesStorageKey);
127+
if (!stored) return {};
128+
129+
const cellSizes = JSON.parse(stored) as Record<string, { size: number }>;
130+
const columnSizing: Record<string, number> = {};
131+
132+
// Convert stored format { [columnId]: { size: number } } to TanStack format { [columnId]: number }
133+
for (const [columnId, sizeData] of Object.entries(cellSizes)) {
134+
if (sizeData?.size && typeof sizeData.size === "number") {
135+
columnSizing[columnId] = sizeData.size;
136+
}
137+
}
138+
139+
return columnSizing;
140+
} catch (error) {
141+
console.warn("Failed to restore column sizes from localStorage:", error);
142+
return {};
143+
}
144+
}, [props.cellSizesStorageKey]);
145+
146+
const [internalColumnSizing, setInternalColumnSizing] = useState<Record<string, number>>(restoredColumnSizing);
147+
114148
// Use controlled activeRowId if onRowClick is provided (parent controls state via clicks)
115149
// OR if activeRowId is explicitly provided (not undefined)
116150
// When onRowClick is provided, activeRowId is read-only for display purposes
@@ -131,7 +165,10 @@ export const DataTable = <T extends DataShape>(props: DataTableProps<T>) => {
131165
const columnsWithHeaders = useMemo(() => {
132166
return baseColumns.map((col) => {
133167
// TanStack Table uses accessorKey as id if id is not explicitly set
134-
const columnId = col.id || (col as any).accessorKey;
168+
const extendedCol = col as ExtendedDataTableColumnDef<T[number]>;
169+
const columnId =
170+
extendedCol.id ||
171+
("accessorKey" in extendedCol && extendedCol.accessorKey ? String(extendedCol.accessorKey) : undefined);
135172

136173
// Get current sort state for this column
137174
const currentSort = sorting.length > 0 ? sorting[0] : null;
@@ -155,6 +192,7 @@ export const DataTable = <T extends DataShape>(props: DataTableProps<T>) => {
155192
isDesc={isDesc}
156193
enableSorting={columnSortingEnabled}
157194
originalHeader={originalHeader}
195+
help={extendedCol.help}
158196
/>
159197
),
160198
};
@@ -277,6 +315,13 @@ export const DataTable = <T extends DataShape>(props: DataTableProps<T>) => {
277315
columnVisibility: props.columnVisibility,
278316
rowSelection,
279317
sorting,
318+
columnSizing: internalColumnSizing,
319+
},
320+
onColumnSizingChange: (updater) => {
321+
setInternalColumnSizing((old) => {
322+
const newState = typeof updater === "function" ? updater(old) : updater;
323+
return newState;
324+
});
280325
},
281326
onSortingChange: (updater) => {
282327
if (isSortingControlled && controlledOnSortingChange) {
@@ -308,7 +353,9 @@ export const DataTable = <T extends DataShape>(props: DataTableProps<T>) => {
308353
: undefined,
309354
getRowId: (row, index) => {
310355
// Use id if available, otherwise fall back to index
311-
return (row as any)?.id?.toString() ?? index.toString();
356+
// Note: 'row' parameter is the row data object itself, not a Row object
357+
const rowId = (row as any)?.id;
358+
return rowId !== undefined ? String(rowId) : String(index);
312359
},
313360
columnResizeMode: "onChange",
314361
enableSorting: enableSorting,
@@ -655,6 +702,7 @@ export type HeaderProps<T> = {
655702
isDesc?: boolean;
656703
enableSorting?: boolean;
657704
originalHeader?: string | React.ReactNode;
705+
help?: string; // Optional help text to display in a tooltip with info icon
658706
};
659707

660708
export const Header = <T,>({
@@ -663,6 +711,7 @@ export const Header = <T,>({
663711
isDesc = false,
664712
enableSorting = false,
665713
originalHeader,
714+
help,
666715
}: HeaderProps<T>) => {
667716
// Get header label - use originalHeader if provided, otherwise try to extract from columnDef
668717
let headerLabel: string | React.ReactNode = undefined;
@@ -680,24 +729,29 @@ export const Header = <T,>({
680729
return null;
681730
}
682731

732+
const headerContent = (
733+
<div className={cn(styles.headerContent, help && "gap-tighter")}>
734+
<div className="flex items-center gap-2">
735+
<Typography variant="label" size="small" className={cn(isSorted && styles.headerTextSorted)}>
736+
{headerLabel}
737+
</Typography>
738+
{help && (
739+
<Tooltip title={help} alignment="top-center">
740+
<IconInfoOutline width={18} height={18} className="text-neutral-content-subtler cursor-help shrink-0" />
741+
</Tooltip>
742+
)}
743+
</div>
744+
{enableSorting && (
745+
<div className={cn(styles.headerIcon, isSorted === true && styles.headerIconVisible)}>
746+
{isSorted ? isDesc ? <IconSortUp /> : <IconSortDown /> : <IconSortDown />}
747+
</div>
748+
)}
749+
</div>
750+
);
751+
683752
if (!enableSorting) {
684-
return (
685-
<Typography variant="label" size="small">
686-
{headerLabel}
687-
</Typography>
688-
);
753+
return headerContent;
689754
}
690755

691-
// Determine icon: when sorted, show current direction; when hovering unsorted, show next direction (asc)
692-
const sortIcon = isSorted ? isDesc ? <IconSortUp /> : <IconSortDown /> : <IconSortDown />;
693-
694-
return (
695-
<div className={styles.headerContent}>
696-
<Typography variant="label" size="small" className={cn(isSorted && styles.headerTextSorted)}>
697-
{headerLabel}
698-
</Typography>
699-
{/* Always render icon container for sortable columns - CSS handles visibility */}
700-
<div className={cn(styles.headerIcon, isSorted === true && styles.headerIconVisible)}>{sortIcon}</div>
701-
</div>
702-
);
756+
return headerContent;
703757
};

web/libs/ui/src/lib/data-table/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
export { DataTable } from "./data-table";
2+
export type { ExtendedDataTableColumnDef } from "./data-table";
23
export type {
34
DataTableProps,
45
DataShape,
5-
DataTableHeaders,
6-
DataTableCells,
7-
DataTableSizes,
86
HeaderProps,
97
} from "./data-table";
108
export { Header } from "./data-table";
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
.monthCalendar {
2+
text-align: center;
3+
}
4+
5+
.daysWeek {
6+
display: table-row;
7+
cursor: default;
8+
}
9+
10+
.monthWrapper {
11+
display: grid;
12+
gap: var(--spacing-tighter);
13+
}
14+
15+
.labels {
16+
height: 40px;
17+
width: 40px;
18+
display: table-cell;
19+
font-size: 11px;
20+
color: var(--color-neutral-content-subtler);
21+
vertical-align: middle;
22+
}
23+
24+
.week {
25+
display: table-row;
26+
}
27+
28+
.day {
29+
display: table-cell;
30+
height: 40px;
31+
width: 40px;
32+
font-size: 14px;
33+
vertical-align: middle;
34+
text-align: center;
35+
transition: all 150ms ease-out;
36+
37+
&.inRange {
38+
background: var(--color-primary-emphasis);
39+
}
40+
41+
&.rangeStart {
42+
border-top-left-radius: 50%;
43+
border-bottom-left-radius: 50%;
44+
background: var(--color-primary-emphasis);
45+
}
46+
47+
&.rangeEnd {
48+
border-top-right-radius: 50%;
49+
border-bottom-right-radius: 50%;
50+
background: var(--color-primary-emphasis);
51+
}
52+
53+
&.disabled {
54+
cursor: default;
55+
color: var(--color-neutral-content-subtlest);
56+
pointer-events: none;
57+
background: unset;
58+
}
59+
}
60+
61+
.highlight {
62+
display: table-cell;
63+
position: relative;
64+
left: 4px;
65+
height: 32px;
66+
width: 32px;
67+
font-size: 14px;
68+
vertical-align: middle;
69+
cursor: pointer;
70+
border-radius: 50%;
71+
transition: all 150ms ease-out;
72+
border: 2px solid transparent;
73+
74+
&:hover {
75+
border: 2px solid var(--color-primary-border);
76+
color: var(--color-neutral-content);
77+
border-radius: 50%;
78+
}
79+
80+
&.inRange {
81+
&:hover {
82+
border: 2px solid var(--color-primary-border);
83+
}
84+
}
85+
86+
&.disabled {
87+
cursor: default;
88+
color: var(--color-neutral-content-subtlest);
89+
pointer-events: none;
90+
}
91+
92+
&.today {
93+
&::before {
94+
content: '';
95+
position: absolute;
96+
left: calc(50% - 2px);
97+
bottom: 0;
98+
height: 4px;
99+
width: 4px;
100+
background: var(--color-primary-surface);
101+
border-radius: 4px;
102+
}
103+
}
104+
105+
&.selected {
106+
border-radius: 50%;
107+
background: var(--color-primary-surface);
108+
color: var(--color-primary-surface-content);
109+
110+
&:hover {
111+
color: var(--color-neutral-on-dark-content);
112+
}
113+
}
114+
}
115+

0 commit comments

Comments
 (0)