@tuturuuu/ui 0.25.0 → 0.25.3
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/CHANGELOG.md +26 -0
- package/biome.json +1 -1
- package/package.json +13 -12
- package/src/components/ui/chat/chat-sidebar-items.tsx +25 -19
- package/src/components/ui/chat/chat-utils.test.ts +88 -2
- package/src/components/ui/chat/chat-workspace.tsx +1 -0
- package/src/components/ui/chat/composer-attachment-chip.tsx +128 -0
- package/src/components/ui/chat/external-message-content.test.ts +41 -0
- package/src/components/ui/chat/external-message-content.ts +28 -0
- package/src/components/ui/chat/message-bubble.tsx +4 -2
- package/src/components/ui/chat/message-composer.tsx +121 -33
- package/src/components/ui/chat/message-links.test.tsx +14 -0
- package/src/components/ui/chat/utils.ts +39 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +2 -6
- package/src/components/ui/custom/__tests__/workspace-select-invitations.test.tsx +141 -0
- package/src/components/ui/custom/education/courses/course-row-actions.tsx +1 -1
- package/src/components/ui/custom/education/modules/course-module-row-actions.tsx +1 -1
- package/src/components/ui/custom/notification-popover-client.tsx +97 -57
- package/src/components/ui/custom/structure.test.tsx +55 -1
- package/src/components/ui/custom/structure.tsx +10 -1
- package/src/components/ui/custom/tables/custom-data-table.tsx +3 -2
- package/src/components/ui/custom/tables/data-table-column-header.tsx +4 -3
- package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +4 -3
- package/src/components/ui/custom/tables/data-table-pagination.tsx +6 -6
- package/src/components/ui/custom/tables/data-table-toolbar.tsx +5 -5
- package/src/components/ui/custom/tables/data-table-view-options.tsx +4 -3
- package/src/components/ui/custom/tables/data-table.tsx +69 -30
- package/src/components/ui/custom/workspace-select-icon.tsx +58 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +202 -0
- package/src/components/ui/custom/workspace-select.tsx +43 -61
- package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
- package/src/components/ui/finance/invoices/columns.tsx +4 -2
- package/src/components/ui/finance/invoices/pending-columns.tsx +1 -1
- package/src/components/ui/finance/invoices/row-actions.tsx +1 -1
- package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/categories/columns.tsx +4 -2
- package/src/components/ui/finance/transactions/categories/row-actions.tsx +1 -1
- package/src/components/ui/finance/transactions/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/columns.tsx +4 -2
- package/src/components/ui/finance/transactions/row-actions.tsx +1 -1
- package/src/components/ui/finance/wallets/columns.tsx +4 -2
- package/src/components/ui/finance/wallets/row-actions.tsx +1 -1
- package/src/hooks/use-notifications.ts +16 -2
|
@@ -1,33 +1,73 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
type ColumnDef,
|
|
5
4
|
type ColumnFiltersState,
|
|
5
|
+
type ColumnVisibilityState,
|
|
6
|
+
columnFacetingFeature,
|
|
7
|
+
columnFilteringFeature,
|
|
8
|
+
columnVisibilityFeature,
|
|
9
|
+
createFacetedRowModel,
|
|
10
|
+
createFacetedUniqueValues,
|
|
11
|
+
createFilteredRowModel,
|
|
12
|
+
createSortedRowModel,
|
|
6
13
|
flexRender,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
globalFilteringFeature,
|
|
15
|
+
type ReactTable,
|
|
16
|
+
type RowData,
|
|
17
|
+
rowPaginationFeature,
|
|
18
|
+
rowSelectionFeature,
|
|
19
|
+
rowSortingFeature,
|
|
13
20
|
type SortingState,
|
|
14
|
-
|
|
15
|
-
type
|
|
21
|
+
type Column as TanStackColumn,
|
|
22
|
+
type ColumnDef as TanStackColumnDef,
|
|
23
|
+
type Row as TanStackRow,
|
|
24
|
+
tableFeatures,
|
|
25
|
+
useTable,
|
|
16
26
|
} from '@tanstack/react-table';
|
|
17
27
|
import { cn } from '@tuturuuu/utils/format';
|
|
18
28
|
import { type ReactNode, useState } from 'react';
|
|
19
29
|
import { Card } from '../../card';
|
|
20
30
|
import {
|
|
21
|
-
Table,
|
|
22
31
|
TableBody,
|
|
23
32
|
TableCell,
|
|
24
33
|
TableHead,
|
|
25
34
|
TableHeader,
|
|
26
35
|
TableRow,
|
|
36
|
+
Table as UiTable,
|
|
27
37
|
} from '../../table';
|
|
28
38
|
import { DataTablePagination } from './data-table-pagination';
|
|
29
39
|
import { DataTableToolbar } from './data-table-toolbar';
|
|
30
40
|
|
|
41
|
+
export const dataTableFeatures = tableFeatures({
|
|
42
|
+
columnFilteringFeature,
|
|
43
|
+
globalFilteringFeature,
|
|
44
|
+
columnFacetingFeature,
|
|
45
|
+
rowSortingFeature,
|
|
46
|
+
rowPaginationFeature,
|
|
47
|
+
rowSelectionFeature,
|
|
48
|
+
columnVisibilityFeature,
|
|
49
|
+
filteredRowModel: createFilteredRowModel(),
|
|
50
|
+
sortedRowModel: createSortedRowModel(),
|
|
51
|
+
facetedRowModel: createFacetedRowModel(),
|
|
52
|
+
facetedUniqueValues: createFacetedUniqueValues(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const EMPTY_DATA: never[] = [];
|
|
56
|
+
|
|
57
|
+
export type DataTableFeatures = typeof dataTableFeatures;
|
|
58
|
+
export type ColumnDef<TData extends RowData, TValue = any> = TanStackColumnDef<
|
|
59
|
+
DataTableFeatures,
|
|
60
|
+
TData,
|
|
61
|
+
TValue
|
|
62
|
+
>;
|
|
63
|
+
export type Row<TData extends RowData> = TanStackRow<DataTableFeatures, TData>;
|
|
64
|
+
export type Column<TData extends RowData, TValue = unknown> = TanStackColumn<
|
|
65
|
+
DataTableFeatures,
|
|
66
|
+
TData,
|
|
67
|
+
TValue
|
|
68
|
+
>;
|
|
69
|
+
export type Table<TData extends RowData> = ReactTable<DataTableFeatures, TData>;
|
|
70
|
+
|
|
31
71
|
function isInteractiveTarget(target: EventTarget | null) {
|
|
32
72
|
if (!(target instanceof HTMLElement)) return false;
|
|
33
73
|
return Boolean(
|
|
@@ -59,26 +99,27 @@ export interface ColumnGeneratorOptions<
|
|
|
59
99
|
/**
|
|
60
100
|
* Type for column generator functions that create table columns.
|
|
61
101
|
*/
|
|
62
|
-
export type ColumnGenerator<TData
|
|
102
|
+
export type ColumnGenerator<TData extends RowData, TValue = unknown> = (
|
|
63
103
|
options: ColumnGeneratorOptions<TData, TValue>
|
|
64
|
-
) => ColumnDef<TData
|
|
104
|
+
) => ColumnDef<TData>[];
|
|
65
105
|
|
|
66
|
-
export interface DataTableProps<TData, TValue> {
|
|
106
|
+
export interface DataTableProps<TData extends RowData, TValue> {
|
|
67
107
|
hideToolbar?: boolean;
|
|
68
108
|
hidePagination?: boolean;
|
|
69
|
-
columns?: ColumnDef<TData
|
|
109
|
+
columns?: ColumnDef<TData>[];
|
|
70
110
|
filters?: ReactNode[] | ReactNode;
|
|
71
111
|
extraColumns?: any[];
|
|
72
112
|
extraData?: any;
|
|
73
113
|
newObjectTitle?: string;
|
|
74
114
|
editContent?: ReactNode;
|
|
115
|
+
emptyState?: ReactNode;
|
|
75
116
|
namespace?: string | undefined;
|
|
76
117
|
data?: TData[];
|
|
77
118
|
count?: number | null;
|
|
78
119
|
pageIndex?: number;
|
|
79
120
|
pageSize?: number;
|
|
80
121
|
defaultQuery?: string;
|
|
81
|
-
defaultVisibility?:
|
|
122
|
+
defaultVisibility?: ColumnVisibilityState;
|
|
82
123
|
disableSearch?: boolean;
|
|
83
124
|
isFiltered?: boolean;
|
|
84
125
|
enableServerSideSorting?: boolean;
|
|
@@ -114,7 +155,7 @@ export interface DataTableProps<TData, TValue> {
|
|
|
114
155
|
rowWrapper?: (row: React.ReactElement, rowData: TData) => React.ReactElement;
|
|
115
156
|
}
|
|
116
157
|
|
|
117
|
-
export function DataTable<TData, TValue>({
|
|
158
|
+
export function DataTable<TData extends RowData, TValue>({
|
|
118
159
|
hideToolbar = false,
|
|
119
160
|
hidePagination = false,
|
|
120
161
|
columns,
|
|
@@ -123,6 +164,7 @@ export function DataTable<TData, TValue>({
|
|
|
123
164
|
extraData,
|
|
124
165
|
newObjectTitle,
|
|
125
166
|
editContent,
|
|
167
|
+
emptyState,
|
|
126
168
|
namespace,
|
|
127
169
|
data,
|
|
128
170
|
count,
|
|
@@ -155,7 +197,7 @@ export function DataTable<TData, TValue>({
|
|
|
155
197
|
}: DataTableProps<TData, TValue>) {
|
|
156
198
|
const [rowSelection, setRowSelection] = useState({});
|
|
157
199
|
const [columnVisibility, setColumnVisibility] =
|
|
158
|
-
useState<
|
|
200
|
+
useState<ColumnVisibilityState>(defaultVisibility);
|
|
159
201
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
160
202
|
const [sorting, setSorting] = useState<SortingState>(
|
|
161
203
|
enableServerSideSorting && currentSortBy && currentSortOrder
|
|
@@ -163,8 +205,9 @@ export function DataTable<TData, TValue>({
|
|
|
163
205
|
: []
|
|
164
206
|
);
|
|
165
207
|
|
|
166
|
-
const table =
|
|
167
|
-
|
|
208
|
+
const table = useTable({
|
|
209
|
+
features: dataTableFeatures,
|
|
210
|
+
data: data || EMPTY_DATA,
|
|
168
211
|
columns:
|
|
169
212
|
columnGenerator && t
|
|
170
213
|
? columnGenerator({ t, namespace, extraColumns, extraData })
|
|
@@ -215,14 +258,7 @@ export function DataTable<TData, TValue>({
|
|
|
215
258
|
: setSorting,
|
|
216
259
|
onColumnFiltersChange: setColumnFilters,
|
|
217
260
|
onColumnVisibilityChange: setColumnVisibility,
|
|
218
|
-
getCoreRowModel: getCoreRowModel(),
|
|
219
|
-
getFilteredRowModel: getFilteredRowModel(),
|
|
220
|
-
getSortedRowModel: enableServerSideSorting
|
|
221
|
-
? undefined
|
|
222
|
-
: getSortedRowModel(),
|
|
223
261
|
manualSorting: enableServerSideSorting,
|
|
224
|
-
getFacetedRowModel: getFacetedRowModel(),
|
|
225
|
-
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
226
262
|
});
|
|
227
263
|
|
|
228
264
|
return (
|
|
@@ -250,7 +286,7 @@ export function DataTable<TData, TValue>({
|
|
|
250
286
|
/>
|
|
251
287
|
)}
|
|
252
288
|
<Card className={tableCardClassName}>
|
|
253
|
-
<
|
|
289
|
+
<UiTable className={tableClassName}>
|
|
254
290
|
<TableHeader>
|
|
255
291
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
256
292
|
<TableRow key={headerGroup.id}>
|
|
@@ -339,16 +375,19 @@ export function DataTable<TData, TValue>({
|
|
|
339
375
|
columns?.length ||
|
|
340
376
|
1
|
|
341
377
|
}
|
|
342
|
-
className=
|
|
378
|
+
className={cn(
|
|
379
|
+
'text-center',
|
|
380
|
+
emptyState ? 'p-0' : 'h-24 opacity-60'
|
|
381
|
+
)}
|
|
343
382
|
>
|
|
344
383
|
{data
|
|
345
|
-
? `${t?.('common.no-results')}.`
|
|
384
|
+
? emptyState || `${t?.('common.no-results')}.`
|
|
346
385
|
: `${t?.('common.loading')}…`}
|
|
347
386
|
</TableCell>
|
|
348
387
|
</TableRow>
|
|
349
388
|
)}
|
|
350
389
|
</TableBody>
|
|
351
|
-
</
|
|
390
|
+
</UiTable>
|
|
352
391
|
</Card>
|
|
353
392
|
|
|
354
393
|
{hidePagination ||
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import Image from 'next/image';
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
|
4
|
+
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
5
|
+
import { resolveWorkspaceAvatarUrl } from './workspace-select-helpers';
|
|
6
|
+
|
|
7
|
+
export function WorkspaceIcon({
|
|
8
|
+
name,
|
|
9
|
+
avatarUrl,
|
|
10
|
+
className,
|
|
11
|
+
fallbackLogoUrl = TUTURUUU_LOGO_URL,
|
|
12
|
+
}: {
|
|
13
|
+
name?: string | null;
|
|
14
|
+
avatarUrl?: string | null;
|
|
15
|
+
className?: string;
|
|
16
|
+
fallbackLogoUrl?: string;
|
|
17
|
+
}) {
|
|
18
|
+
const resolvedAvatarUrl = resolveWorkspaceAvatarUrl(avatarUrl);
|
|
19
|
+
const shouldSkipFallbackOptimization = /^https?:\/\//u.test(fallbackLogoUrl);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Avatar
|
|
23
|
+
className={cn(
|
|
24
|
+
'h-5 max-h-5 min-h-5 w-5 min-w-5 max-w-5 flex-none overflow-hidden',
|
|
25
|
+
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm',
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
<AvatarImage
|
|
30
|
+
alt={name || 'Workspace'}
|
|
31
|
+
className={cn(
|
|
32
|
+
'h-full w-full object-cover',
|
|
33
|
+
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
34
|
+
)}
|
|
35
|
+
src={
|
|
36
|
+
resolvedAvatarUrl ||
|
|
37
|
+
(name ? `https://avatar.vercel.sh/${name}.png` : undefined)
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
<AvatarFallback
|
|
41
|
+
className={cn(
|
|
42
|
+
'h-full w-full text-xs',
|
|
43
|
+
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<Image
|
|
47
|
+
alt=""
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
className="h-full w-full object-cover"
|
|
50
|
+
height={20}
|
|
51
|
+
src={fallbackLogoUrl}
|
|
52
|
+
unoptimized={shouldSkipFallbackOptimization}
|
|
53
|
+
width={20}
|
|
54
|
+
/>
|
|
55
|
+
</AvatarFallback>
|
|
56
|
+
</Avatar>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { Check, Loader2, Mail, RefreshCw, X } from '@tuturuuu/icons';
|
|
5
|
+
import type { WorkspaceInvitationRecord } from '@tuturuuu/internal-api/workspaces';
|
|
6
|
+
import {
|
|
7
|
+
acceptWorkspaceInvite,
|
|
8
|
+
declineWorkspaceInvite,
|
|
9
|
+
listWorkspaceInvitations,
|
|
10
|
+
} from '@tuturuuu/internal-api/workspaces';
|
|
11
|
+
import { useTranslations } from 'next-intl';
|
|
12
|
+
import { toast } from 'sonner';
|
|
13
|
+
import { Button } from '../button';
|
|
14
|
+
import { CommandGroup, CommandItem } from '../command';
|
|
15
|
+
import { WorkspaceIcon } from './workspace-select-icon';
|
|
16
|
+
|
|
17
|
+
export function useWorkspaceInvitations({
|
|
18
|
+
cacheScope,
|
|
19
|
+
enabled,
|
|
20
|
+
onAccepted,
|
|
21
|
+
onDeclined,
|
|
22
|
+
}: {
|
|
23
|
+
cacheScope?: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
onAccepted: (invitation: WorkspaceInvitationRecord) => void;
|
|
26
|
+
onDeclined: () => void;
|
|
27
|
+
}) {
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
const query = useQuery({
|
|
31
|
+
queryKey: ['workspace-invitations', ...(cacheScope ? [cacheScope] : [])],
|
|
32
|
+
queryFn: async () => (await listWorkspaceInvitations()).invitations,
|
|
33
|
+
enabled,
|
|
34
|
+
retry: 1,
|
|
35
|
+
staleTime: 30_000,
|
|
36
|
+
});
|
|
37
|
+
const invitations = query.data ?? [];
|
|
38
|
+
const mutation = useMutation({
|
|
39
|
+
mutationFn: async ({
|
|
40
|
+
action,
|
|
41
|
+
invitation,
|
|
42
|
+
}: {
|
|
43
|
+
action: 'accept' | 'decline';
|
|
44
|
+
invitation: WorkspaceInvitationRecord;
|
|
45
|
+
}) => {
|
|
46
|
+
if (action === 'accept') {
|
|
47
|
+
await acceptWorkspaceInvite(invitation.workspace.id);
|
|
48
|
+
} else {
|
|
49
|
+
await declineWorkspaceInvite(invitation.workspace.id);
|
|
50
|
+
}
|
|
51
|
+
return { action, invitation };
|
|
52
|
+
},
|
|
53
|
+
onSuccess: async ({ action, invitation }) => {
|
|
54
|
+
await Promise.all([
|
|
55
|
+
queryClient.invalidateQueries({ queryKey: ['workspace-invitations'] }),
|
|
56
|
+
queryClient.invalidateQueries({ queryKey: ['workspaces'] }),
|
|
57
|
+
queryClient.invalidateQueries({ queryKey: ['user-workspaces'] }),
|
|
58
|
+
queryClient.invalidateQueries({ queryKey: ['workspace-user'] }),
|
|
59
|
+
queryClient.invalidateQueries({ queryKey: ['current-user'] }),
|
|
60
|
+
queryClient.invalidateQueries({ queryKey: ['user'] }),
|
|
61
|
+
queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
if (action === 'accept') {
|
|
65
|
+
toast.success(t('workspace-invitation.accept-success'));
|
|
66
|
+
onAccepted(invitation);
|
|
67
|
+
} else {
|
|
68
|
+
toast.success(t('workspace-invitation.decline-success'));
|
|
69
|
+
onDeclined();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
onError: (error, { action }) => {
|
|
73
|
+
toast.error(
|
|
74
|
+
t(
|
|
75
|
+
action === 'accept'
|
|
76
|
+
? 'workspace-invitation.accept-error'
|
|
77
|
+
: 'workspace-invitation.decline-error'
|
|
78
|
+
),
|
|
79
|
+
{
|
|
80
|
+
description: error instanceof Error ? error.message : undefined,
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { invitations, mutation, query };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function WorkspaceInvitationItems({
|
|
90
|
+
controller,
|
|
91
|
+
fallbackLogoUrl,
|
|
92
|
+
}: {
|
|
93
|
+
controller: ReturnType<typeof useWorkspaceInvitations>;
|
|
94
|
+
fallbackLogoUrl: string;
|
|
95
|
+
}) {
|
|
96
|
+
const t = useTranslations();
|
|
97
|
+
const { invitations, mutation, query } = controller;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
{invitations.length > 0 && (
|
|
102
|
+
<CommandGroup
|
|
103
|
+
heading={`${t('workspace-invitation.list-eyebrow')} (${invitations.length})`}
|
|
104
|
+
>
|
|
105
|
+
{invitations.map((invitation) => {
|
|
106
|
+
const workspaceName =
|
|
107
|
+
invitation.workspace.name ||
|
|
108
|
+
invitation.workspace.handle ||
|
|
109
|
+
invitation.workspace.id;
|
|
110
|
+
const isPending =
|
|
111
|
+
mutation.isPending &&
|
|
112
|
+
mutation.variables?.invitation.workspace.id ===
|
|
113
|
+
invitation.workspace.id;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className="flex items-stretch gap-1 [&:has([cmdk-item][hidden])]:hidden"
|
|
118
|
+
key={`${invitation.workspace.id}-${invitation.source}`}
|
|
119
|
+
>
|
|
120
|
+
<CommandItem
|
|
121
|
+
className="min-w-0 flex-1 gap-2"
|
|
122
|
+
disabled={isPending}
|
|
123
|
+
onSelect={() =>
|
|
124
|
+
mutation.mutate({ action: 'accept', invitation })
|
|
125
|
+
}
|
|
126
|
+
value={`${workspaceName} ${invitation.workspace.handle || ''} ${invitation.source} ${invitation.type}`}
|
|
127
|
+
>
|
|
128
|
+
<WorkspaceIcon
|
|
129
|
+
avatarUrl={
|
|
130
|
+
invitation.workspace.avatar_url ||
|
|
131
|
+
invitation.workspace.logo_url
|
|
132
|
+
}
|
|
133
|
+
fallbackLogoUrl={fallbackLogoUrl}
|
|
134
|
+
name={workspaceName}
|
|
135
|
+
/>
|
|
136
|
+
<div className="min-w-0 flex-1">
|
|
137
|
+
<div className="truncate text-xs">{workspaceName}</div>
|
|
138
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
139
|
+
<Mail className="size-3" />
|
|
140
|
+
{t(
|
|
141
|
+
`workspace-invitation.${
|
|
142
|
+
invitation.source === 'email'
|
|
143
|
+
? 'email-invite'
|
|
144
|
+
: 'direct-invite'
|
|
145
|
+
}`
|
|
146
|
+
)}
|
|
147
|
+
<span aria-hidden="true">·</span>
|
|
148
|
+
{invitation.type === 'GUEST'
|
|
149
|
+
? t('common.guest_access')
|
|
150
|
+
: t('common.members')}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{isPending && mutation.variables?.action === 'accept' ? (
|
|
154
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
155
|
+
) : (
|
|
156
|
+
<Check className="size-3.5" />
|
|
157
|
+
)}
|
|
158
|
+
<span className="sr-only">
|
|
159
|
+
{t('workspace-invitation.accept')}
|
|
160
|
+
</span>
|
|
161
|
+
</CommandItem>
|
|
162
|
+
<Button
|
|
163
|
+
aria-label={t('workspace-invitation.reject')}
|
|
164
|
+
className="size-8 self-center"
|
|
165
|
+
disabled={isPending}
|
|
166
|
+
onClick={() =>
|
|
167
|
+
mutation.mutate({ action: 'decline', invitation })
|
|
168
|
+
}
|
|
169
|
+
size="icon"
|
|
170
|
+
title={t('workspace-invitation.reject')}
|
|
171
|
+
type="button"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
>
|
|
174
|
+
{isPending && mutation.variables?.action === 'decline' ? (
|
|
175
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
176
|
+
) : (
|
|
177
|
+
<X className="size-3.5" />
|
|
178
|
+
)}
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</CommandGroup>
|
|
184
|
+
)}
|
|
185
|
+
{query.isError && (
|
|
186
|
+
<CommandGroup>
|
|
187
|
+
<CommandItem
|
|
188
|
+
onSelect={() => query.refetch()}
|
|
189
|
+
value="retry workspace invitations"
|
|
190
|
+
>
|
|
191
|
+
{query.isFetching ? (
|
|
192
|
+
<Loader2 className="size-4 animate-spin" />
|
|
193
|
+
) : (
|
|
194
|
+
<RefreshCw className="size-4" />
|
|
195
|
+
)}
|
|
196
|
+
{t('common.retry')}
|
|
197
|
+
</CommandItem>
|
|
198
|
+
</CommandGroup>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
import { cn } from '@tuturuuu/utils/format';
|
|
28
28
|
import { workspaceHandleSchema } from '@tuturuuu/utils/workspace-handle';
|
|
29
29
|
import { WORKSPACE_LIMIT_ERROR_CODE } from '@tuturuuu/utils/workspace-limits';
|
|
30
|
-
import Image from 'next/image';
|
|
31
30
|
import { usePathname, useRouter } from 'next/navigation';
|
|
32
31
|
import { useLocale, useTranslations } from 'next-intl';
|
|
33
32
|
import type { ReactNode } from 'react';
|
|
@@ -37,7 +36,6 @@ import { z } from 'zod';
|
|
|
37
36
|
import { useForm } from '../../../hooks/use-form';
|
|
38
37
|
import { useWorkspaceUser } from '../../../hooks/use-workspace-user';
|
|
39
38
|
import { zodResolver } from '../../../resolvers';
|
|
40
|
-
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
|
41
39
|
import { Badge } from '../badge';
|
|
42
40
|
import { Button } from '../button';
|
|
43
41
|
import {
|
|
@@ -74,6 +72,11 @@ import {
|
|
|
74
72
|
normalizeWorkspaceSwitchPath,
|
|
75
73
|
resolveWorkspaceAvatarUrl,
|
|
76
74
|
} from './workspace-select-helpers';
|
|
75
|
+
import { WorkspaceIcon } from './workspace-select-icon';
|
|
76
|
+
import {
|
|
77
|
+
useWorkspaceInvitations,
|
|
78
|
+
WorkspaceInvitationItems,
|
|
79
|
+
} from './workspace-select-invitations';
|
|
77
80
|
import { useOpenWorkspaceSelectWhenRevealed } from './workspace-select-reveal';
|
|
78
81
|
|
|
79
82
|
const FormSchema = z.object({
|
|
@@ -84,59 +87,6 @@ const JoinWorkspaceByHandleFormSchema = z.object({
|
|
|
84
87
|
handle: workspaceHandleSchema,
|
|
85
88
|
});
|
|
86
89
|
|
|
87
|
-
function WorkspaceIcon({
|
|
88
|
-
name,
|
|
89
|
-
avatarUrl,
|
|
90
|
-
className,
|
|
91
|
-
fallbackLogoUrl = TUTURUUU_LOGO_URL,
|
|
92
|
-
}: {
|
|
93
|
-
name?: string | null;
|
|
94
|
-
avatarUrl?: string | null;
|
|
95
|
-
className?: string;
|
|
96
|
-
fallbackLogoUrl?: string;
|
|
97
|
-
}) {
|
|
98
|
-
const resolvedAvatarUrl = resolveWorkspaceAvatarUrl(avatarUrl);
|
|
99
|
-
const shouldSkipFallbackOptimization = /^https?:\/\//u.test(fallbackLogoUrl);
|
|
100
|
-
|
|
101
|
-
return (
|
|
102
|
-
<Avatar
|
|
103
|
-
className={cn(
|
|
104
|
-
'h-5 max-h-5 min-h-5 w-5 min-w-5 max-w-5 flex-none overflow-hidden',
|
|
105
|
-
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm',
|
|
106
|
-
className
|
|
107
|
-
)}
|
|
108
|
-
>
|
|
109
|
-
<AvatarImage
|
|
110
|
-
src={
|
|
111
|
-
resolvedAvatarUrl ||
|
|
112
|
-
(name ? `https://avatar.vercel.sh/${name}.png` : undefined)
|
|
113
|
-
}
|
|
114
|
-
alt={name || 'Workspace'}
|
|
115
|
-
className={cn(
|
|
116
|
-
'h-full w-full object-cover',
|
|
117
|
-
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
118
|
-
)}
|
|
119
|
-
/>
|
|
120
|
-
<AvatarFallback
|
|
121
|
-
className={cn(
|
|
122
|
-
'h-full w-full text-xs',
|
|
123
|
-
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
124
|
-
)}
|
|
125
|
-
>
|
|
126
|
-
<Image
|
|
127
|
-
alt=""
|
|
128
|
-
aria-hidden="true"
|
|
129
|
-
className="h-full w-full object-cover"
|
|
130
|
-
height={20}
|
|
131
|
-
src={fallbackLogoUrl}
|
|
132
|
-
unoptimized={shouldSkipFallbackOptimization}
|
|
133
|
-
width={20}
|
|
134
|
-
/>
|
|
135
|
-
</AvatarFallback>
|
|
136
|
-
</Avatar>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
90
|
export function WorkspaceSelect({
|
|
141
91
|
wsId,
|
|
142
92
|
hideLeading,
|
|
@@ -152,6 +102,7 @@ export function WorkspaceSelect({
|
|
|
152
102
|
triggerClassName,
|
|
153
103
|
popoverModal = false,
|
|
154
104
|
platformWorkspaceSetupUrl,
|
|
105
|
+
cacheScope,
|
|
155
106
|
}: {
|
|
156
107
|
wsId: string;
|
|
157
108
|
hideLeading?: boolean;
|
|
@@ -172,6 +123,8 @@ export function WorkspaceSelect({
|
|
|
172
123
|
popoverModal?: boolean;
|
|
173
124
|
/** Platform origin used to prepare a newly created satellite workspace. */
|
|
174
125
|
platformWorkspaceSetupUrl?: string;
|
|
126
|
+
/** Authenticated identity used to isolate user-specific picker caches. */
|
|
127
|
+
cacheScope?: string;
|
|
175
128
|
}) {
|
|
176
129
|
const t = useTranslations();
|
|
177
130
|
const locale = useLocale();
|
|
@@ -184,7 +137,7 @@ export function WorkspaceSelect({
|
|
|
184
137
|
? resolveWorkspaceId(wsId)
|
|
185
138
|
: undefined;
|
|
186
139
|
const { data: listedWorkspaces } = useQuery({
|
|
187
|
-
queryKey: ['workspaces'],
|
|
140
|
+
queryKey: ['workspaces', ...(cacheScope ? [cacheScope] : [])],
|
|
188
141
|
queryFn: fetchWorkspaces,
|
|
189
142
|
enabled: !!wsId,
|
|
190
143
|
});
|
|
@@ -195,7 +148,11 @@ export function WorkspaceSelect({
|
|
|
195
148
|
)
|
|
196
149
|
);
|
|
197
150
|
const { data: currentWorkspaceFallback } = useQuery({
|
|
198
|
-
queryKey: [
|
|
151
|
+
queryKey: [
|
|
152
|
+
'workspace-select-current-workspace',
|
|
153
|
+
resolvedWorkspaceId,
|
|
154
|
+
...(cacheScope ? [cacheScope] : []),
|
|
155
|
+
],
|
|
199
156
|
queryFn: async () =>
|
|
200
157
|
(await getWorkspace(resolvedWorkspaceId!)) as InternalApiWorkspaceSummary,
|
|
201
158
|
enabled: Boolean(resolvedWorkspaceId && !hasListedCurrentWorkspace),
|
|
@@ -206,7 +163,6 @@ export function WorkspaceSelect({
|
|
|
206
163
|
currentWorkspaceFallback
|
|
207
164
|
);
|
|
208
165
|
const { data: currentUser } = useWorkspaceUser();
|
|
209
|
-
|
|
210
166
|
const defaultWorkspaceId = currentUser?.default_workspace_id || null;
|
|
211
167
|
|
|
212
168
|
const form = useForm({
|
|
@@ -228,6 +184,18 @@ export function WorkspaceSelect({
|
|
|
228
184
|
|
|
229
185
|
const [loading, setLoading] = useState(false);
|
|
230
186
|
const [joiningByHandle, setJoiningByHandle] = useState(false);
|
|
187
|
+
const invitationController = useWorkspaceInvitations({
|
|
188
|
+
cacheScope,
|
|
189
|
+
enabled: Boolean(wsId),
|
|
190
|
+
onAccepted: (invitation) => {
|
|
191
|
+
setOpen(false);
|
|
192
|
+
const slug = invitation.workspace.handle || invitation.workspace.id;
|
|
193
|
+
router.push(getWorkspaceLandingPath(slug));
|
|
194
|
+
router.refresh();
|
|
195
|
+
},
|
|
196
|
+
onDeclined: () => router.refresh(),
|
|
197
|
+
});
|
|
198
|
+
const invitations = invitationController.invitations;
|
|
231
199
|
|
|
232
200
|
const updateDefaultWorkspaceMutation = useMutation({
|
|
233
201
|
mutationFn: (workspaceId: string) =>
|
|
@@ -257,7 +225,7 @@ export function WorkspaceSelect({
|
|
|
257
225
|
},
|
|
258
226
|
});
|
|
259
227
|
|
|
260
|
-
|
|
228
|
+
function getWorkspaceLandingPath(nextSlug: string) {
|
|
261
229
|
if (resolveNextPathname) {
|
|
262
230
|
return resolveNextPathname({
|
|
263
231
|
currentPathname: pathname || `/${wsId}`,
|
|
@@ -268,7 +236,7 @@ export function WorkspaceSelect({
|
|
|
268
236
|
return customRedirectSuffix
|
|
269
237
|
? `/${nextSlug}/${customRedirectSuffix}`
|
|
270
238
|
: `/${nextSlug}`;
|
|
271
|
-
}
|
|
239
|
+
}
|
|
272
240
|
|
|
273
241
|
async function onSubmit(formData: z.infer<typeof FormSchema>) {
|
|
274
242
|
if (disableCreateNewWorkspace) return;
|
|
@@ -450,7 +418,8 @@ export function WorkspaceSelect({
|
|
|
450
418
|
}
|
|
451
419
|
};
|
|
452
420
|
|
|
453
|
-
const hasSelectableWorkspaces =
|
|
421
|
+
const hasSelectableWorkspaces =
|
|
422
|
+
workspaces.length > 0 || invitations.length > 0;
|
|
454
423
|
useOpenWorkspaceSelectWhenRevealed(hasSelectableWorkspaces, setOpen);
|
|
455
424
|
|
|
456
425
|
const workspace =
|
|
@@ -616,6 +585,15 @@ export function WorkspaceSelect({
|
|
|
616
585
|
</Badge>
|
|
617
586
|
)}
|
|
618
587
|
</div>
|
|
588
|
+
{invitations.length > 0 && (
|
|
589
|
+
<Badge
|
|
590
|
+
aria-label={`${invitations.length} ${t('workspace-invitation.list-eyebrow')}`}
|
|
591
|
+
className="h-5 min-w-5 justify-center px-1 text-[10px]"
|
|
592
|
+
variant="destructive"
|
|
593
|
+
>
|
|
594
|
+
{invitations.length > 99 ? '99+' : invitations.length}
|
|
595
|
+
</Badge>
|
|
596
|
+
)}
|
|
619
597
|
{hideLeading || (
|
|
620
598
|
<ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
|
621
599
|
)}
|
|
@@ -626,6 +604,10 @@ export function WorkspaceSelect({
|
|
|
626
604
|
<CommandInput autoFocus placeholder="Search workspace..." />
|
|
627
605
|
<CommandEmpty>No workspace found.</CommandEmpty>
|
|
628
606
|
<CommandList className="max-h-64">
|
|
607
|
+
<WorkspaceInvitationItems
|
|
608
|
+
controller={invitationController}
|
|
609
|
+
fallbackLogoUrl={fallbackLogoUrl}
|
|
610
|
+
/>
|
|
629
611
|
{groups.map((group) => (
|
|
630
612
|
<CommandGroup key={group.label} heading={group.label}>
|
|
631
613
|
{group.teams.map(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/react-table';
|
|
2
1
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import type { ColumnDef } from '@tuturuuu/ui/custom/tables/data-table';
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { invoiceColumns } from './columns';
|
|
5
5
|
|
|
@@ -19,8 +19,8 @@ function InvoicePriceCell({ price }: { price: number }) {
|
|
|
19
19
|
});
|
|
20
20
|
const priceColumn = columns.find(
|
|
21
21
|
(column) =>
|
|
22
|
-
(column as ColumnDef<unknown
|
|
23
|
-
|
|
22
|
+
(column as ColumnDef<Record<string, unknown>> & { accessorKey?: string })
|
|
23
|
+
.accessorKey === 'price'
|
|
24
24
|
);
|
|
25
25
|
|
|
26
26
|
if (typeof priceColumn?.cell !== 'function') return null;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type { ColumnDef } from '@tanstack/react-table';
|
|
4
3
|
import type { Invoice } from '@tuturuuu/types/primitives/Invoice';
|
|
5
4
|
import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar';
|
|
6
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ColumnDef,
|
|
7
|
+
ColumnGeneratorOptions,
|
|
8
|
+
} from '@tuturuuu/ui/custom/tables/data-table';
|
|
7
9
|
import { DataTableColumnHeader } from '@tuturuuu/ui/custom/tables/data-table-column-header';
|
|
8
10
|
import { InvoiceRowActions } from '@tuturuuu/ui/finance/invoices/row-actions';
|
|
9
11
|
import {
|