@mostrom/app-shell 0.1.0
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/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
SortableContext,
|
|
4
|
+
useSortable,
|
|
5
|
+
verticalListSortingStrategy,
|
|
6
|
+
} from "@dnd-kit/sortable";
|
|
7
|
+
import type { Assignee, ColumnDefinition, ItemOpenEvent, Section, TableTaskAction } from "./types";
|
|
8
|
+
import { SortableRow } from "./sortable-row";
|
|
9
|
+
|
|
10
|
+
interface SortableSectionProps<T> {
|
|
11
|
+
sortableId: string;
|
|
12
|
+
section: Section<T>;
|
|
13
|
+
columns: ColumnDefinition<T>[];
|
|
14
|
+
onToggle: () => void;
|
|
15
|
+
onRename: (name: string) => void;
|
|
16
|
+
onDelete: () => void;
|
|
17
|
+
onAddRow: () => void;
|
|
18
|
+
onCellEdit: (itemIndex: number, columnId: string, value: unknown) => void;
|
|
19
|
+
onRowComplete: (itemIndex: number, completed: boolean) => void;
|
|
20
|
+
onRowAction?: (itemIndex: number, itemId: string, action: TableTaskAction) => void;
|
|
21
|
+
/** Callback fired when an item is opened */
|
|
22
|
+
onItemOpen?: (event: ItemOpenEvent<T>) => void;
|
|
23
|
+
getItemId: (item: T) => string;
|
|
24
|
+
isItemCompleted?: (item: T) => boolean;
|
|
25
|
+
isDragDisabled: boolean;
|
|
26
|
+
collapseDuringDrag?: boolean;
|
|
27
|
+
dropIndicatorPosition?: "before" | "after" | null;
|
|
28
|
+
rowDropIndicatorIndex?: number | null;
|
|
29
|
+
/** List of available assignees for the assignee selector */
|
|
30
|
+
assignees?: Assignee[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function SortableSection<T>({
|
|
34
|
+
sortableId,
|
|
35
|
+
section,
|
|
36
|
+
columns,
|
|
37
|
+
onToggle,
|
|
38
|
+
onRename,
|
|
39
|
+
onDelete,
|
|
40
|
+
onAddRow,
|
|
41
|
+
onCellEdit,
|
|
42
|
+
onRowComplete,
|
|
43
|
+
onRowAction,
|
|
44
|
+
onItemOpen,
|
|
45
|
+
getItemId,
|
|
46
|
+
isItemCompleted,
|
|
47
|
+
isDragDisabled,
|
|
48
|
+
collapseDuringDrag = false,
|
|
49
|
+
dropIndicatorPosition = null,
|
|
50
|
+
rowDropIndicatorIndex = null,
|
|
51
|
+
assignees,
|
|
52
|
+
}: SortableSectionProps<T>) {
|
|
53
|
+
const [isEditing, setIsEditing] = React.useState(false);
|
|
54
|
+
const [editName, setEditName] = React.useState(section.name);
|
|
55
|
+
const [showContextMenu, setShowContextMenu] = React.useState(false);
|
|
56
|
+
const [contextMenuPos, setContextMenuPos] = React.useState({ x: 0, y: 0 });
|
|
57
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
58
|
+
|
|
59
|
+
const {
|
|
60
|
+
attributes,
|
|
61
|
+
listeners,
|
|
62
|
+
setNodeRef,
|
|
63
|
+
transform,
|
|
64
|
+
transition,
|
|
65
|
+
isDragging,
|
|
66
|
+
} = useSortable({
|
|
67
|
+
id: sortableId,
|
|
68
|
+
data: { type: "section", sectionId: section.id },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const style: React.CSSProperties = {
|
|
72
|
+
// Use translate-only transform to avoid text scaling artifacts while sorting.
|
|
73
|
+
transform: transform
|
|
74
|
+
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
|
75
|
+
: undefined,
|
|
76
|
+
transition,
|
|
77
|
+
opacity: isDragging ? 0.75 : 1,
|
|
78
|
+
};
|
|
79
|
+
const isContentCollapsed =
|
|
80
|
+
Boolean(section.collapsed) || isDragging || collapseDuringDrag;
|
|
81
|
+
const rowIds = section.items.map(getItemId);
|
|
82
|
+
|
|
83
|
+
const handleDoubleClick = () => {
|
|
84
|
+
setIsEditing(true);
|
|
85
|
+
setEditName(section.name);
|
|
86
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleBlur = () => {
|
|
90
|
+
setIsEditing(false);
|
|
91
|
+
if (editName.trim() && editName !== section.name) {
|
|
92
|
+
onRename(editName.trim());
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
97
|
+
if (e.key === "Enter") {
|
|
98
|
+
handleBlur();
|
|
99
|
+
} else if (e.key === "Escape") {
|
|
100
|
+
setIsEditing(false);
|
|
101
|
+
setEditName(section.name);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleContextMenu = (e: React.MouseEvent) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
setContextMenuPos({ x: e.clientX, y: e.clientY });
|
|
108
|
+
setShowContextMenu(true);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
const handleClick = () => setShowContextMenu(false);
|
|
113
|
+
if (showContextMenu) {
|
|
114
|
+
document.addEventListener("click", handleClick);
|
|
115
|
+
return () => document.removeEventListener("click", handleClick);
|
|
116
|
+
}
|
|
117
|
+
}, [showContextMenu]);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={setNodeRef}
|
|
122
|
+
style={style}
|
|
123
|
+
className={`sectioned-list-section ${isDragging ? "dragging" : ""}`}
|
|
124
|
+
data-testid={`section-${section.id}`}
|
|
125
|
+
>
|
|
126
|
+
{dropIndicatorPosition === "before" && (
|
|
127
|
+
<div className="section-drop-indicator section-drop-indicator-before" />
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<div
|
|
131
|
+
className="sectioned-list-section-header"
|
|
132
|
+
onContextMenu={handleContextMenu}
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
className="section-drag-handle"
|
|
136
|
+
{...attributes}
|
|
137
|
+
{...listeners}
|
|
138
|
+
data-testid={`section-drag-handle-${section.id}`}
|
|
139
|
+
title="Drag to reorder section"
|
|
140
|
+
>
|
|
141
|
+
<span className="drag-icon">⡇</span>
|
|
142
|
+
</button>
|
|
143
|
+
|
|
144
|
+
<button
|
|
145
|
+
className="section-collapse-toggle"
|
|
146
|
+
onClick={onToggle}
|
|
147
|
+
data-testid={`section-toggle-${section.id}`}
|
|
148
|
+
aria-expanded={!isContentCollapsed}
|
|
149
|
+
>
|
|
150
|
+
<span className={`chevron ${isContentCollapsed ? "collapsed" : ""}`}>
|
|
151
|
+
▼
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
|
|
155
|
+
{isEditing ? (
|
|
156
|
+
<input
|
|
157
|
+
ref={inputRef}
|
|
158
|
+
type="text"
|
|
159
|
+
className="section-name-input"
|
|
160
|
+
value={editName}
|
|
161
|
+
onChange={(e) => setEditName(e.target.value)}
|
|
162
|
+
onBlur={handleBlur}
|
|
163
|
+
onKeyDown={handleKeyDown}
|
|
164
|
+
data-testid={`section-name-input-${section.id}`}
|
|
165
|
+
/>
|
|
166
|
+
) : (
|
|
167
|
+
<span
|
|
168
|
+
className="section-name"
|
|
169
|
+
onDoubleClick={handleDoubleClick}
|
|
170
|
+
data-testid={`section-name-${section.id}`}
|
|
171
|
+
>
|
|
172
|
+
{section.name}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
<span className="section-count">({section.items.length})</span>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{showContextMenu && (
|
|
180
|
+
<div
|
|
181
|
+
className="context-menu"
|
|
182
|
+
style={{ left: contextMenuPos.x, top: contextMenuPos.y }}
|
|
183
|
+
data-testid={`section-context-menu-${section.id}`}
|
|
184
|
+
>
|
|
185
|
+
<button onClick={onDelete} data-testid={`section-delete-${section.id}`}>
|
|
186
|
+
Delete section
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* Collapse content during drag for smooth Asana-style section dragging */}
|
|
192
|
+
{!isContentCollapsed && (
|
|
193
|
+
<div className="sectioned-list-section-content">
|
|
194
|
+
<SortableContext items={rowIds} strategy={verticalListSortingStrategy}>
|
|
195
|
+
{section.items.map((item, index) => (
|
|
196
|
+
<React.Fragment key={getItemId(item)}>
|
|
197
|
+
{rowDropIndicatorIndex === index && (
|
|
198
|
+
<div className="row-drop-indicator" />
|
|
199
|
+
)}
|
|
200
|
+
<SortableRow
|
|
201
|
+
item={item}
|
|
202
|
+
itemId={getItemId(item)}
|
|
203
|
+
index={index}
|
|
204
|
+
columns={columns}
|
|
205
|
+
sectionId={section.id}
|
|
206
|
+
onCellEdit={(columnId, value) => onCellEdit(index, columnId, value)}
|
|
207
|
+
onComplete={(completed) => onRowComplete(index, completed)}
|
|
208
|
+
onTaskAction={
|
|
209
|
+
onRowAction
|
|
210
|
+
? (action) => onRowAction(index, getItemId(item), action)
|
|
211
|
+
: undefined
|
|
212
|
+
}
|
|
213
|
+
onItemOpen={
|
|
214
|
+
onItemOpen
|
|
215
|
+
? (source) =>
|
|
216
|
+
onItemOpen({
|
|
217
|
+
item,
|
|
218
|
+
itemId: getItemId(item),
|
|
219
|
+
sectionId: section.id,
|
|
220
|
+
source,
|
|
221
|
+
})
|
|
222
|
+
: undefined
|
|
223
|
+
}
|
|
224
|
+
isCompleted={isItemCompleted?.(item) ?? false}
|
|
225
|
+
isDragDisabled={isDragDisabled}
|
|
226
|
+
assignees={assignees}
|
|
227
|
+
/>
|
|
228
|
+
</React.Fragment>
|
|
229
|
+
))}
|
|
230
|
+
{rowDropIndicatorIndex === section.items.length && (
|
|
231
|
+
<div className="row-drop-indicator" />
|
|
232
|
+
)}
|
|
233
|
+
</SortableContext>
|
|
234
|
+
|
|
235
|
+
<div
|
|
236
|
+
className="add-task-row"
|
|
237
|
+
onClick={onAddRow}
|
|
238
|
+
data-testid={`add-task-${section.id}`}
|
|
239
|
+
>
|
|
240
|
+
<span className="add-task-icon">+</span>
|
|
241
|
+
<span className="add-task-text">Add task...</span>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{dropIndicatorPosition === "after" && (
|
|
247
|
+
<div className="section-drop-indicator section-drop-indicator-after" />
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import Badge from "@cloudscape-design/components/badge";
|
|
3
|
+
import type { Assignee, ColumnDefinition, TableBadgeColor } from "./types";
|
|
4
|
+
import { AssigneeSelector } from "@/components/ui/assignee-selector";
|
|
5
|
+
|
|
6
|
+
function toText(value: unknown): string {
|
|
7
|
+
if (value == null) return "";
|
|
8
|
+
if (typeof value === "string") return value;
|
|
9
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getInitials(name: string): string {
|
|
14
|
+
const parts = name
|
|
15
|
+
.trim()
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
|
|
19
|
+
if (parts.length === 0) return "?";
|
|
20
|
+
|
|
21
|
+
return parts
|
|
22
|
+
.slice(0, 2)
|
|
23
|
+
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
24
|
+
.join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asNumber(value: unknown): number | null {
|
|
28
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
29
|
+
if (typeof value === "string" && value.trim()) {
|
|
30
|
+
const parsed = Number(value);
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatCurrency(value: unknown, column: ColumnDefinition<unknown>): string {
|
|
37
|
+
const numeric = asNumber(value);
|
|
38
|
+
if (numeric == null) return "";
|
|
39
|
+
return new Intl.NumberFormat(column.currencyLocale ?? "en-US", {
|
|
40
|
+
style: "currency",
|
|
41
|
+
currency: column.currencyCode ?? "USD",
|
|
42
|
+
minimumFractionDigits: 0,
|
|
43
|
+
maximumFractionDigits: 0,
|
|
44
|
+
}).format(numeric);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getBadgeColor(column: ColumnDefinition<unknown>, value: string): TableBadgeColor {
|
|
48
|
+
return column.badgeColorMap?.[value] ?? column.defaultBadgeColor ?? "blue";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TableCellContentProps<T> {
|
|
52
|
+
column: ColumnDefinition<T>;
|
|
53
|
+
item: T;
|
|
54
|
+
rowIndex: number;
|
|
55
|
+
/** List of available assignees for the assignee selector */
|
|
56
|
+
assignees?: Assignee[];
|
|
57
|
+
/** Callback when a cell value changes */
|
|
58
|
+
onFieldChange?: (field: string, value: unknown) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function TableCellContent<T>({ column, item, rowIndex, assignees, onFieldChange }: TableCellContentProps<T>) {
|
|
62
|
+
const [isAssigneeSelectorOpen, setIsAssigneeSelectorOpen] = React.useState(false);
|
|
63
|
+
void rowIndex;
|
|
64
|
+
const value = (item as Record<string, unknown>)[column.field];
|
|
65
|
+
|
|
66
|
+
switch (column.type) {
|
|
67
|
+
case "currency": {
|
|
68
|
+
return formatCurrency(value, column as ColumnDefinition<unknown>);
|
|
69
|
+
}
|
|
70
|
+
case "badge": {
|
|
71
|
+
const text = toText(value);
|
|
72
|
+
if (!text) return null;
|
|
73
|
+
return <Badge color={getBadgeColor(column as ColumnDefinition<unknown>, text)}>{text}</Badge>;
|
|
74
|
+
}
|
|
75
|
+
case "avatar": {
|
|
76
|
+
const text = toText(value);
|
|
77
|
+
const canEditAssignee = Boolean(
|
|
78
|
+
onFieldChange &&
|
|
79
|
+
assignees &&
|
|
80
|
+
assignees.length > 0
|
|
81
|
+
);
|
|
82
|
+
const currentAssignee = assignees?.find((a) => a.name === text);
|
|
83
|
+
|
|
84
|
+
if (canEditAssignee) {
|
|
85
|
+
return (
|
|
86
|
+
<AssigneeSelector
|
|
87
|
+
open={isAssigneeSelectorOpen}
|
|
88
|
+
onOpenChange={setIsAssigneeSelectorOpen}
|
|
89
|
+
assignees={assignees ?? []}
|
|
90
|
+
selectedId={currentAssignee?.id}
|
|
91
|
+
onSelect={(assignee) => {
|
|
92
|
+
if (!onFieldChange) return;
|
|
93
|
+
onFieldChange(column.field, assignee?.name ?? "");
|
|
94
|
+
}}
|
|
95
|
+
mode="popover"
|
|
96
|
+
align="start"
|
|
97
|
+
>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className="table-avatar table-avatar-editable"
|
|
101
|
+
title={text ? `${text} (click to change)` : "Assign someone"}
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
setIsAssigneeSelectorOpen(true);
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{getInitials(text)}
|
|
108
|
+
</button>
|
|
109
|
+
</AssigneeSelector>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="table-avatar" title={text || "Unassigned"}>
|
|
115
|
+
{getInitials(text)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
case "text":
|
|
120
|
+
default: {
|
|
121
|
+
return toText(value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getEditableCellValue<T>(column: ColumnDefinition<T>, item: T): string {
|
|
127
|
+
const value = (item as Record<string, unknown>)[column.field];
|
|
128
|
+
return toText(value);
|
|
129
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Assignee } from "@/components/ui/assignee-selector";
|
|
3
|
+
|
|
4
|
+
// Re-export Assignee from shared location
|
|
5
|
+
export type { Assignee };
|
|
6
|
+
|
|
7
|
+
export type TableBadgeColor = "red" | "blue" | "green" | "grey";
|
|
8
|
+
export type TableColumnType = "text" | "currency" | "badge" | "avatar";
|
|
9
|
+
export type TableTaskAction =
|
|
10
|
+
| "duplicate"
|
|
11
|
+
| "markComplete"
|
|
12
|
+
| "addSubtask"
|
|
13
|
+
| "openTaskDetails"
|
|
14
|
+
| "openInNewTab"
|
|
15
|
+
| "copyTaskLink"
|
|
16
|
+
| "delete";
|
|
17
|
+
|
|
18
|
+
/** Event fired when an item is opened (click, context menu, or keyboard) */
|
|
19
|
+
export interface ItemOpenEvent<T> {
|
|
20
|
+
item: T;
|
|
21
|
+
itemId: string;
|
|
22
|
+
sectionId: string;
|
|
23
|
+
source: "click" | "context-menu" | "keyboard";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Configuration for the built-in drawer */
|
|
27
|
+
export interface DrawerProps {
|
|
28
|
+
/** Which side the drawer opens from */
|
|
29
|
+
side?: "right" | "left";
|
|
30
|
+
/** Width of the drawer */
|
|
31
|
+
width?: number | string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ColumnDefinition<T = Record<string, unknown>> {
|
|
35
|
+
id: string;
|
|
36
|
+
header: string;
|
|
37
|
+
field: Extract<keyof T, string>;
|
|
38
|
+
type?: TableColumnType;
|
|
39
|
+
editable?: boolean;
|
|
40
|
+
badgeColorMap?: Record<string, TableBadgeColor>;
|
|
41
|
+
defaultBadgeColor?: TableBadgeColor;
|
|
42
|
+
currencyCode?: string;
|
|
43
|
+
currencyLocale?: string;
|
|
44
|
+
width?: number;
|
|
45
|
+
minWidth?: number;
|
|
46
|
+
sortable?: boolean;
|
|
47
|
+
resizable?: boolean;
|
|
48
|
+
visible?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Section<T = Record<string, unknown>> {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
items: T[];
|
|
55
|
+
collapsed?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SortState {
|
|
59
|
+
columnId: string | null;
|
|
60
|
+
direction: "asc" | "desc" | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ColumnPreferences {
|
|
64
|
+
order: string[];
|
|
65
|
+
visibility: Record<string, boolean>;
|
|
66
|
+
widths: Record<string, number>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SectionedListTableProps<T = Record<string, unknown>> {
|
|
70
|
+
columns: ColumnDefinition<T>[];
|
|
71
|
+
sections: Section<T>[];
|
|
72
|
+
onSectionReorder?: (sections: Section<T>[]) => void;
|
|
73
|
+
onRowReorder?: (sectionId: string, items: T[]) => void;
|
|
74
|
+
onRowMoveToSection?: (
|
|
75
|
+
item: T,
|
|
76
|
+
fromSectionId: string,
|
|
77
|
+
toSectionId: string,
|
|
78
|
+
targetIndex?: number
|
|
79
|
+
) => void;
|
|
80
|
+
onAddColumn?: () => void;
|
|
81
|
+
onAddRow?: (sectionId: string) => void;
|
|
82
|
+
onAddSection?: () => void;
|
|
83
|
+
onDeleteSection?: (sectionId: string) => void;
|
|
84
|
+
onRenameSection?: (sectionId: string, newName: string) => void;
|
|
85
|
+
onToggleSection?: (sectionId: string) => void;
|
|
86
|
+
onCellEdit?: (sectionId: string, itemIndex: number, columnId: string, value: unknown) => void;
|
|
87
|
+
onRowComplete?: (sectionId: string, itemIndex: number, completed: boolean) => void;
|
|
88
|
+
onRowAction?: (
|
|
89
|
+
sectionId: string,
|
|
90
|
+
itemIndex: number,
|
|
91
|
+
itemId: string,
|
|
92
|
+
action: TableTaskAction
|
|
93
|
+
) => void;
|
|
94
|
+
onColumnReorder?: (columns: ColumnDefinition<T>[]) => void;
|
|
95
|
+
onColumnResize?: (columnId: string, width: number) => void;
|
|
96
|
+
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void;
|
|
97
|
+
onSort?: (sort: SortState) => void;
|
|
98
|
+
onFilter?: (filterText: string) => void;
|
|
99
|
+
filterText?: string;
|
|
100
|
+
sort?: SortState;
|
|
101
|
+
storageKey?: string;
|
|
102
|
+
getItemId: (item: T) => string;
|
|
103
|
+
isItemCompleted?: (item: T) => boolean;
|
|
104
|
+
/** List of available assignees for the assignee selector */
|
|
105
|
+
assignees?: Assignee[];
|
|
106
|
+
|
|
107
|
+
// Item Details Drawer
|
|
108
|
+
/** Callback fired when an item is opened (click, context menu, or keyboard) */
|
|
109
|
+
onItemOpen?: (event: ItemOpenEvent<T>) => void;
|
|
110
|
+
/** Render function for item details - when provided, enables built-in drawer */
|
|
111
|
+
renderItemDetails?: (item: T) => ReactNode;
|
|
112
|
+
/** Configuration for the built-in drawer */
|
|
113
|
+
drawerProps?: DrawerProps;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface DragState {
|
|
117
|
+
isDragging: boolean;
|
|
118
|
+
dragType: "section" | "row" | "column" | null;
|
|
119
|
+
activeId: string | null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { ColumnDefinition, ColumnPreferences } from "./types";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PREFERENCES: ColumnPreferences = {
|
|
5
|
+
order: [],
|
|
6
|
+
visibility: {},
|
|
7
|
+
widths: {},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function useColumnPreferences<T>(
|
|
11
|
+
columns: ColumnDefinition<T>[],
|
|
12
|
+
storageKey?: string
|
|
13
|
+
) {
|
|
14
|
+
const [preferences, setPreferences] = React.useState<ColumnPreferences>(() => {
|
|
15
|
+
if (!storageKey) return DEFAULT_PREFERENCES;
|
|
16
|
+
try {
|
|
17
|
+
const stored = localStorage.getItem(`column-prefs-${storageKey}`);
|
|
18
|
+
if (stored) {
|
|
19
|
+
return JSON.parse(stored);
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Ignore localStorage errors
|
|
23
|
+
}
|
|
24
|
+
return DEFAULT_PREFERENCES;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Save to localStorage when preferences change
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (!storageKey) return;
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(`column-prefs-${storageKey}`, JSON.stringify(preferences));
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore localStorage errors
|
|
34
|
+
}
|
|
35
|
+
}, [preferences, storageKey]);
|
|
36
|
+
|
|
37
|
+
// Get ordered and filtered columns based on preferences
|
|
38
|
+
const orderedColumns = React.useMemo(() => {
|
|
39
|
+
const columnMap = new Map(columns.map((col) => [col.id, col]));
|
|
40
|
+
const order = preferences.order.length > 0 ? preferences.order : columns.map((c) => c.id);
|
|
41
|
+
|
|
42
|
+
return order
|
|
43
|
+
.filter((id) => columnMap.has(id))
|
|
44
|
+
.map((id) => {
|
|
45
|
+
const col = columnMap.get(id)!;
|
|
46
|
+
const isVisible = preferences.visibility[id] ?? col.visible ?? true;
|
|
47
|
+
const width = preferences.widths[id] ?? col.width;
|
|
48
|
+
return { ...col, visible: isVisible, width };
|
|
49
|
+
})
|
|
50
|
+
.concat(
|
|
51
|
+
// Add any new columns not in saved order
|
|
52
|
+
columns.filter((col) => !order.includes(col.id)).map((col) => ({
|
|
53
|
+
...col,
|
|
54
|
+
visible: preferences.visibility[col.id] ?? col.visible ?? true,
|
|
55
|
+
width: preferences.widths[col.id] ?? col.width,
|
|
56
|
+
}))
|
|
57
|
+
);
|
|
58
|
+
}, [columns, preferences]);
|
|
59
|
+
|
|
60
|
+
const visibleColumns = React.useMemo(
|
|
61
|
+
() => orderedColumns.filter((col) => col.visible !== false),
|
|
62
|
+
[orderedColumns]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const setColumnOrder = React.useCallback((order: string[]) => {
|
|
66
|
+
setPreferences((prev) => ({ ...prev, order }));
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const setColumnVisibility = React.useCallback((columnId: string, visible: boolean) => {
|
|
70
|
+
setPreferences((prev) => ({
|
|
71
|
+
...prev,
|
|
72
|
+
visibility: { ...prev.visibility, [columnId]: visible },
|
|
73
|
+
}));
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const setColumnWidth = React.useCallback((columnId: string, width: number) => {
|
|
77
|
+
setPreferences((prev) => ({
|
|
78
|
+
...prev,
|
|
79
|
+
widths: { ...prev.widths, [columnId]: width },
|
|
80
|
+
}));
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const resetPreferences = React.useCallback(() => {
|
|
84
|
+
setPreferences(DEFAULT_PREFERENCES);
|
|
85
|
+
if (storageKey) {
|
|
86
|
+
try {
|
|
87
|
+
localStorage.removeItem(`column-prefs-${storageKey}`);
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, [storageKey]);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
orderedColumns,
|
|
96
|
+
visibleColumns,
|
|
97
|
+
preferences,
|
|
98
|
+
setColumnOrder,
|
|
99
|
+
setColumnVisibility,
|
|
100
|
+
setColumnWidth,
|
|
101
|
+
resetPreferences,
|
|
102
|
+
};
|
|
103
|
+
}
|