@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/biome.json +1 -1
  3. package/package.json +13 -12
  4. package/src/components/ui/chat/chat-sidebar-items.tsx +25 -19
  5. package/src/components/ui/chat/chat-utils.test.ts +88 -2
  6. package/src/components/ui/chat/chat-workspace.tsx +1 -0
  7. package/src/components/ui/chat/composer-attachment-chip.tsx +128 -0
  8. package/src/components/ui/chat/external-message-content.test.ts +41 -0
  9. package/src/components/ui/chat/external-message-content.ts +28 -0
  10. package/src/components/ui/chat/message-bubble.tsx +4 -2
  11. package/src/components/ui/chat/message-composer.tsx +121 -33
  12. package/src/components/ui/chat/message-links.test.tsx +14 -0
  13. package/src/components/ui/chat/utils.ts +39 -3
  14. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +2 -6
  15. package/src/components/ui/custom/__tests__/workspace-select-invitations.test.tsx +141 -0
  16. package/src/components/ui/custom/education/courses/course-row-actions.tsx +1 -1
  17. package/src/components/ui/custom/education/modules/course-module-row-actions.tsx +1 -1
  18. package/src/components/ui/custom/notification-popover-client.tsx +97 -57
  19. package/src/components/ui/custom/structure.test.tsx +55 -1
  20. package/src/components/ui/custom/structure.tsx +10 -1
  21. package/src/components/ui/custom/tables/custom-data-table.tsx +3 -2
  22. package/src/components/ui/custom/tables/data-table-column-header.tsx +4 -3
  23. package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +4 -3
  24. package/src/components/ui/custom/tables/data-table-pagination.tsx +6 -6
  25. package/src/components/ui/custom/tables/data-table-toolbar.tsx +5 -5
  26. package/src/components/ui/custom/tables/data-table-view-options.tsx +4 -3
  27. package/src/components/ui/custom/tables/data-table.tsx +69 -30
  28. package/src/components/ui/custom/workspace-select-icon.tsx +58 -0
  29. package/src/components/ui/custom/workspace-select-invitations.tsx +202 -0
  30. package/src/components/ui/custom/workspace-select.tsx +43 -61
  31. package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
  32. package/src/components/ui/finance/invoices/columns.tsx +4 -2
  33. package/src/components/ui/finance/invoices/pending-columns.tsx +1 -1
  34. package/src/components/ui/finance/invoices/row-actions.tsx +1 -1
  35. package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
  36. package/src/components/ui/finance/transactions/categories/columns.tsx +4 -2
  37. package/src/components/ui/finance/transactions/categories/row-actions.tsx +1 -1
  38. package/src/components/ui/finance/transactions/columns.test.tsx +5 -5
  39. package/src/components/ui/finance/transactions/columns.tsx +4 -2
  40. package/src/components/ui/finance/transactions/row-actions.tsx +1 -1
  41. package/src/components/ui/finance/wallets/columns.tsx +4 -2
  42. package/src/components/ui/finance/wallets/row-actions.tsx +1 -1
  43. 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
- getCoreRowModel,
8
- getFacetedRowModel,
9
- getFacetedUniqueValues,
10
- getFilteredRowModel,
11
- getSortedRowModel,
12
- type Row,
14
+ globalFilteringFeature,
15
+ type ReactTable,
16
+ type RowData,
17
+ rowPaginationFeature,
18
+ rowSelectionFeature,
19
+ rowSortingFeature,
13
20
  type SortingState,
14
- useReactTable,
15
- type VisibilityState,
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 = unknown, TValue = unknown> = (
102
+ export type ColumnGenerator<TData extends RowData, TValue = unknown> = (
63
103
  options: ColumnGeneratorOptions<TData, TValue>
64
- ) => ColumnDef<TData, TValue>[];
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, TValue>[];
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?: VisibilityState;
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<VisibilityState>(defaultVisibility);
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 = useReactTable({
167
- data: data || [],
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
- <Table className={tableClassName}>
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="h-24 text-center opacity-60"
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
- </Table>
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: ['workspace-select-current-workspace', resolvedWorkspaceId],
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
- const getWorkspaceLandingPath = (nextSlug: string) => {
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 = workspaces.length > 0;
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> & { accessorKey?: string }).accessorKey ===
23
- 'price'
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 { ColumnGeneratorOptions } from '@tuturuuu/ui/custom/tables/data-table';
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 {