From 5fe17c80a379959ea835e9ecb8cdf53c560f2460 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:43:59 +0100 Subject: [PATCH 1/7] Add ErrorState components --- apps/app/src/components/ErrorState/index.tsx | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/app/src/components/ErrorState/index.tsx diff --git a/apps/app/src/components/ErrorState/index.tsx b/apps/app/src/components/ErrorState/index.tsx new file mode 100644 index 000000000..26cc29d9c --- /dev/null +++ b/apps/app/src/components/ErrorState/index.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Button } from '@op/ui/Button'; +import type { ReactNode } from 'react'; +import { LuCircleAlert } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +export const ErrorState = ({ + message, + icon, +}: { + message: ReactNode; + icon?: ReactNode; +}) => { + return ( +
+
+
+ {icon ?? } +
+ {message} +
+
+ ); +}; + +export const ErrorStateWithRetry = ({ + message, + icon, + onRetry, + retryLabel, +}: { + message: ReactNode; + icon?: ReactNode; + onRetry: () => void; + retryLabel?: string; +}) => { + const t = useTranslations(); + return ( +
+
+
+ {icon ?? } +
+ {message} + +
+
+ ); +}; From e9b399e54c61e402998cc01d14308a58fdcb06e6 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:46:49 +0100 Subject: [PATCH 2/7] More composable ErrorState --- apps/app/src/components/ErrorState/index.tsx | 36 ++------------------ 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/apps/app/src/components/ErrorState/index.tsx b/apps/app/src/components/ErrorState/index.tsx index 26cc29d9c..5891ca3ed 100644 --- a/apps/app/src/components/ErrorState/index.tsx +++ b/apps/app/src/components/ErrorState/index.tsx @@ -1,52 +1,22 @@ 'use client'; -import { Button } from '@op/ui/Button'; import type { ReactNode } from 'react'; import { LuCircleAlert } from 'react-icons/lu'; -import { useTranslations } from '@/lib/i18n'; - export const ErrorState = ({ - message, - icon, -}: { - message: ReactNode; - icon?: ReactNode; -}) => { - return ( -
-
-
- {icon ?? } -
- {message} -
-
- ); -}; - -export const ErrorStateWithRetry = ({ - message, icon, - onRetry, - retryLabel, + children, }: { - message: ReactNode; icon?: ReactNode; - onRetry: () => void; - retryLabel?: string; + children: ReactNode; }) => { - const t = useTranslations(); return (
{icon ?? }
- {message} - + {children}
); From dea41bb7fab525d93630c566c6fbfdd99c5bc731 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:54:36 +0100 Subject: [PATCH 3/7] Rename to EmptyState --- packages/ui/package.json | 1 + .../index.tsx => packages/ui/src/components/EmptyState.tsx | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) rename apps/app/src/components/ErrorState/index.tsx => packages/ui/src/components/EmptyState.tsx (92%) diff --git a/packages/ui/package.json b/packages/ui/package.json index d8c2edf75..d235dad83 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,7 @@ "./DatePicker": "./src/components/DatePicker.tsx", "./Dialog": "./src/components/Dialog.tsx", "./DropDownButton": "./src/components/DropDownButton.tsx", + "./EmptyState": "./src/components/EmptyState.tsx", "./Field": "./src/components/Field.tsx", "./Form": "./src/components/Form.tsx", "./Header": "./src/components/Header.tsx", diff --git a/apps/app/src/components/ErrorState/index.tsx b/packages/ui/src/components/EmptyState.tsx similarity index 92% rename from apps/app/src/components/ErrorState/index.tsx rename to packages/ui/src/components/EmptyState.tsx index 5891ca3ed..8528fb421 100644 --- a/apps/app/src/components/ErrorState/index.tsx +++ b/packages/ui/src/components/EmptyState.tsx @@ -1,9 +1,7 @@ -'use client'; - import type { ReactNode } from 'react'; import { LuCircleAlert } from 'react-icons/lu'; -export const ErrorState = ({ +export const EmptyState = ({ icon, children, }: { From f5313b1e393ba590071bac8d1e1c1d7f64df9710 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:57:07 +0100 Subject: [PATCH 4/7] Convert other EmptyStates to new component --- .../decisions/EmptyProposalsState.tsx | 19 -- .../app/src/components/decisions/MyBallot.tsx | 8 +- .../decisions/ProfileUsersAccessTable.tsx | 257 ++++++++++++++++++ .../components/decisions/ProposalsList.tsx | 8 +- .../src/components/decisions/ResultsList.tsx | 8 +- .../decisions/pages/ResultsPage.tsx | 8 +- .../decisions/pages/StandardDecisionPage.tsx | 8 +- 7 files changed, 279 insertions(+), 37 deletions(-) delete mode 100644 apps/app/src/components/decisions/EmptyProposalsState.tsx create mode 100644 apps/app/src/components/decisions/ProfileUsersAccessTable.tsx diff --git a/apps/app/src/components/decisions/EmptyProposalsState.tsx b/apps/app/src/components/decisions/EmptyProposalsState.tsx deleted file mode 100644 index aaaa03707..000000000 --- a/apps/app/src/components/decisions/EmptyProposalsState.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; -import { LuLeaf } from 'react-icons/lu'; - -export function EmptyProposalsState({ children }: { children: ReactNode }) { - return ( -
-
- -
-
-
- {children} -
-
-
- ); -} diff --git a/apps/app/src/components/decisions/MyBallot.tsx b/apps/app/src/components/decisions/MyBallot.tsx index ca704b060..716dfdbb4 100644 --- a/apps/app/src/components/decisions/MyBallot.tsx +++ b/apps/app/src/components/decisions/MyBallot.tsx @@ -3,11 +3,11 @@ import { useUser } from '@/utils/UserProvider'; import { trpc } from '@op/api/client'; import { Checkbox } from '@op/ui/Checkbox'; +import { EmptyState } from '@op/ui/EmptyState'; import { Header3 } from '@op/ui/Header'; +import { LuLeaf } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; - -import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCardContent, ProposalCardDescription, @@ -20,11 +20,11 @@ import { VotingProposalCard } from './VotingProposalCard'; export const NoVoteFound = () => { const t = useTranslations(); return ( - + }> {t('You did not vote in this process.')} - + ); }; diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx new file mode 100644 index 000000000..517df112a --- /dev/null +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import type { profileUserEncoder } from '@op/api/encoders'; +import { Button } from '@op/ui/Button'; +import { Select, SelectItem } from '@op/ui/Select'; +import { Skeleton } from '@op/ui/Skeleton'; +import { toast } from '@op/ui/Toast'; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from '@op/ui/ui/table'; +import { useEffect, useState } from 'react'; +import type { SortDescriptor } from 'react-aria-components'; +import type { z } from 'zod'; + +import { useTranslations } from '@/lib/i18n'; + +import { EmptyState } from '@op/ui/EmptyState'; +import { ProfileAvatar } from '@/components/ProfileAvatar'; + +// Infer the ProfileUser type from the encoder +type ProfileUser = z.infer; + +const getProfileUserStatus = (profileUser: ProfileUser): string => { + // Check for status field if available, otherwise derive from data + if ('status' in profileUser && typeof profileUser.status === 'string') { + // Capitalize first letter + return ( + profileUser.status.charAt(0).toUpperCase() + profileUser.status.slice(1) + ); + } + // Default to "Active" for existing profile users + return 'Active'; +}; + +const ProfileUserRoleSelect = ({ + profileUserId, + currentRoleId, + profileId, + roles, +}: { + profileUserId: string; + currentRoleId?: string; + profileId: string; + roles: { id: string; name: string }[]; +}) => { + const t = useTranslations(); + const utils = trpc.useUtils(); + + const updateRoles = trpc.profile.updateUserRoles.useMutation({ + onSuccess: () => { + toast.success({ message: t('Role updated successfully') }); + void utils.profile.listUsers.invalidate({ profileId }); + }, + onError: (error) => { + toast.error({ + message: error.message || t('Failed to update role'), + }); + }, + }); + + const handleRoleChange = (roleId: string) => { + if (roleId && roleId !== currentRoleId) { + updateRoles.mutate({ + profileUserId, + roleIds: [roleId], + }); + } + }; + + return ( + + ); +}; + +// Hook to detect client-side hydration (workaround for React Aria Table SSR issue) +// See: https://github.com/adobe/react-spectrum/issues/4870 +const useIsHydrated = () => { + const [isHydrated, setIsHydrated] = useState(false); + useEffect(() => { + setIsHydrated(true); + }, []); + return isHydrated; +}; + +// Inner table content component +const ProfileUsersAccessTableContent = ({ + profileUsers, + profileId, + sortDescriptor, + onSortChange, + isLoading, +}: { + profileUsers: ProfileUser[]; + profileId: string; + sortDescriptor: SortDescriptor; + onSortChange: (descriptor: SortDescriptor) => void; + isLoading: boolean; +}) => { + const t = useTranslations(); + const isHydrated = useIsHydrated(); + + // Fetch roles with regular query + const { + data: rolesData, + isPending: rolesPending, + isError: rolesError, + } = trpc.organization.getRoles.useQuery(); + + // Don't render table until after hydration due to React Aria SSR limitations + if (!isHydrated || rolesPending) { + return ; + } + + if (rolesError) { + return null; // Error handled by parent + } + + const roles = rolesData?.roles ?? []; + + return ( +
+ {isLoading && ( +
+ +
+ )} + + + + {t('Name')} + + + {t('Email')} + + + {t('Role')} + + + + {profileUsers.map((profileUser) => { + const displayName = + profileUser.profile?.name || + profileUser.name || + (profileUser.email?.split('@')?.[0] ?? 'Unknown'); + const currentRole = profileUser.roles[0]; + const status = getProfileUserStatus(profileUser); + + return ( + + +
+ +
+ + {displayName} + + + {status} + +
+
+
+ + + {profileUser.email} + + + + + +
+ ); + })} +
+
+
+ ); +}; + +// Exported component with loading and error states +export const ProfileUsersAccessTable = ({ + profileUsers, + profileId, + sortDescriptor, + onSortChange, + isLoading, + isError, + onRetry, +}: { + profileUsers: ProfileUser[]; + profileId: string; + sortDescriptor: SortDescriptor; + onSortChange: (descriptor: SortDescriptor) => void; + isLoading: boolean; + isError: boolean; + onRetry: () => void; +}) => { + const t = useTranslations(); + + if (isError) { + return ( + + {t('Members could not be loaded')} + + + ); + } + + return ( + + ); +}; diff --git a/apps/app/src/components/decisions/ProposalsList.tsx b/apps/app/src/components/decisions/ProposalsList.tsx index 6059e32ab..e15c1bf60 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -12,15 +12,15 @@ import { Modal } from '@op/ui/Modal'; import { Select, SelectItem } from '@op/ui/Select'; import { Skeleton } from '@op/ui/Skeleton'; import { Surface } from '@op/ui/Surface'; +import { EmptyState } from '@op/ui/EmptyState'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -import { LuArrowDownToLine } from 'react-icons/lu'; +import { LuArrowDownToLine, LuLeaf } from 'react-icons/lu'; import type { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; import { Bullet } from '../Bullet'; -import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCard, ProposalCardActions, @@ -113,14 +113,14 @@ export const ProposalListSkeleton = () => { const NoProposalsFound = () => { const t = useTranslations(); return ( - + }> {t('No proposals found matching the current filters.')}

{t('Try adjusting your filter selection above.')}

-
+ ); }; diff --git a/apps/app/src/components/decisions/ResultsList.tsx b/apps/app/src/components/decisions/ResultsList.tsx index ac3704988..bcf3be78d 100644 --- a/apps/app/src/components/decisions/ResultsList.tsx +++ b/apps/app/src/components/decisions/ResultsList.tsx @@ -1,11 +1,11 @@ 'use client'; import { trpc } from '@op/api/client'; +import { EmptyState } from '@op/ui/EmptyState'; import { Header3 } from '@op/ui/Header'; +import { LuLeaf } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; - -import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCard, ProposalCardContent, @@ -18,14 +18,14 @@ import { const NoProposalsFound = () => { const t = useTranslations(); return ( - + }> {t('No results yet for this decision.')}

{t('Results are still being worked on.')}

-
+ ); }; diff --git a/apps/app/src/components/decisions/pages/ResultsPage.tsx b/apps/app/src/components/decisions/pages/ResultsPage.tsx index 1f89e5650..bab5be5c7 100644 --- a/apps/app/src/components/decisions/pages/ResultsPage.tsx +++ b/apps/app/src/components/decisions/pages/ResultsPage.tsx @@ -11,11 +11,13 @@ import { useTranslations } from '@/lib/i18n/routing'; import { DecisionActionBar } from '../DecisionActionBar'; import { DecisionHero } from '../DecisionHero'; +import { EmptyState } from '@op/ui/EmptyState'; +import { LuLeaf } from 'react-icons/lu'; + import { DecisionResultsTabPanel, DecisionResultsTabs, } from '../DecisionResultsTabs'; -import { EmptyProposalsState } from '../EmptyProposalsState'; import { MyBallot, NoVoteFound } from '../MyBallot'; import { ProposalListSkeleton } from '../ProposalsList'; import { ResultsList } from '../ResultsList'; @@ -146,14 +148,14 @@ function ResultsPageContent({ + }> {t('Results are still being processed.')}

{t('Check back again shortly for the results.')}

- +
), }} > diff --git a/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx b/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx index 51edb68fa..af2d0e4be 100644 --- a/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx +++ b/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx @@ -8,9 +8,11 @@ import { Suspense } from 'react'; import { useTranslations } from '@/lib/i18n/routing'; +import { EmptyState } from '@op/ui/EmptyState'; +import { LuLeaf } from 'react-icons/lu'; + import { DecisionActionBar } from '../DecisionActionBar'; import { DecisionHero } from '../DecisionHero'; -import { EmptyProposalsState } from '../EmptyProposalsState'; import { MemberParticipationFacePile } from '../MemberParticipationFacePile'; import { ProposalListSkeleton, ProposalsList } from '../ProposalsList'; @@ -135,14 +137,14 @@ export function StandardDecisionPage({
{proposals.length === 0 ? ( - + }> {t('No proposals yet')}

{t('You could be the first one to submit a proposal')}

-
+ ) : ( }> Date: Fri, 23 Jan 2026 21:02:36 +0100 Subject: [PATCH 5/7] Format and add storybook --- .../app/src/components/decisions/MyBallot.tsx | 1 + .../decisions/ProfileUsersAccessTable.tsx | 2 +- .../components/decisions/ProposalsList.tsx | 2 +- .../src/components/decisions/ResultsList.tsx | 1 + .../decisions/pages/ResultsPage.tsx | 5 +- .../decisions/pages/StandardDecisionPage.tsx | 5 +- packages/ui/stories/EmptyState.stories.tsx | 73 +++++++++++++++++++ 7 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 packages/ui/stories/EmptyState.stories.tsx diff --git a/apps/app/src/components/decisions/MyBallot.tsx b/apps/app/src/components/decisions/MyBallot.tsx index 716dfdbb4..f93568f73 100644 --- a/apps/app/src/components/decisions/MyBallot.tsx +++ b/apps/app/src/components/decisions/MyBallot.tsx @@ -8,6 +8,7 @@ import { Header3 } from '@op/ui/Header'; import { LuLeaf } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; + import { ProposalCardContent, ProposalCardDescription, diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index 517df112a..7ec75ffbc 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -3,6 +3,7 @@ import { trpc } from '@op/api/client'; import type { profileUserEncoder } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; +import { EmptyState } from '@op/ui/EmptyState'; import { Select, SelectItem } from '@op/ui/Select'; import { Skeleton } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; @@ -20,7 +21,6 @@ import type { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; -import { EmptyState } from '@op/ui/EmptyState'; import { ProfileAvatar } from '@/components/ProfileAvatar'; // Infer the ProfileUser type from the encoder diff --git a/apps/app/src/components/decisions/ProposalsList.tsx b/apps/app/src/components/decisions/ProposalsList.tsx index e15c1bf60..c8183a716 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -7,12 +7,12 @@ import { match } from '@op/core'; import { Button, ButtonLink } from '@op/ui/Button'; import { Checkbox } from '@op/ui/Checkbox'; import { Dialog, DialogTrigger } from '@op/ui/Dialog'; +import { EmptyState } from '@op/ui/EmptyState'; import { Header3 } from '@op/ui/Header'; import { Modal } from '@op/ui/Modal'; import { Select, SelectItem } from '@op/ui/Select'; import { Skeleton } from '@op/ui/Skeleton'; import { Surface } from '@op/ui/Surface'; -import { EmptyState } from '@op/ui/EmptyState'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { LuArrowDownToLine, LuLeaf } from 'react-icons/lu'; diff --git a/apps/app/src/components/decisions/ResultsList.tsx b/apps/app/src/components/decisions/ResultsList.tsx index bcf3be78d..6362957de 100644 --- a/apps/app/src/components/decisions/ResultsList.tsx +++ b/apps/app/src/components/decisions/ResultsList.tsx @@ -6,6 +6,7 @@ import { Header3 } from '@op/ui/Header'; import { LuLeaf } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; + import { ProposalCard, ProposalCardContent, diff --git a/apps/app/src/components/decisions/pages/ResultsPage.tsx b/apps/app/src/components/decisions/pages/ResultsPage.tsx index bab5be5c7..2ca04fa86 100644 --- a/apps/app/src/components/decisions/pages/ResultsPage.tsx +++ b/apps/app/src/components/decisions/pages/ResultsPage.tsx @@ -3,17 +3,16 @@ import { APIErrorBoundary } from '@/utils/APIErrorBoundary'; import { trpc } from '@op/api/client'; import { match } from '@op/core'; +import { EmptyState } from '@op/ui/EmptyState'; import { Header3 } from '@op/ui/Header'; import { Skeleton } from '@op/ui/Skeleton'; import { Suspense } from 'react'; +import { LuLeaf } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n/routing'; import { DecisionActionBar } from '../DecisionActionBar'; import { DecisionHero } from '../DecisionHero'; -import { EmptyState } from '@op/ui/EmptyState'; -import { LuLeaf } from 'react-icons/lu'; - import { DecisionResultsTabPanel, DecisionResultsTabs, diff --git a/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx b/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx index af2d0e4be..e45c5c74a 100644 --- a/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx +++ b/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx @@ -3,14 +3,13 @@ import { getUniqueSubmitters } from '@/utils/proposalUtils'; import { trpc } from '@op/api/client'; import { match } from '@op/core'; +import { EmptyState } from '@op/ui/EmptyState'; import { Header3 } from '@op/ui/Header'; import { Suspense } from 'react'; +import { LuLeaf } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n/routing'; -import { EmptyState } from '@op/ui/EmptyState'; -import { LuLeaf } from 'react-icons/lu'; - import { DecisionActionBar } from '../DecisionActionBar'; import { DecisionHero } from '../DecisionHero'; import { MemberParticipationFacePile } from '../MemberParticipationFacePile'; diff --git a/packages/ui/stories/EmptyState.stories.tsx b/packages/ui/stories/EmptyState.stories.tsx new file mode 100644 index 000000000..c1ae001f0 --- /dev/null +++ b/packages/ui/stories/EmptyState.stories.tsx @@ -0,0 +1,73 @@ +import { LuFileText, LuInbox, LuSearch, LuUsers } from 'react-icons/lu'; + +import { Button } from '../src/components/Button'; +import { EmptyState } from '../src/components/EmptyState'; + +export default { + title: 'EmptyState', + component: EmptyState, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export const Default = () => ( + +

No items found

+
+); + +export const WithCustomIcon = () => ( + }> +

Your inbox is empty

+
+); + +export const WithDescription = () => ( + }> +

No results found

+

Try adjusting your search or filters

+
+); + +export const WithAction = () => ( + }> +

No team members

+

+ Get started by inviting your first team member +

+ +
+); + +export const Examples = () => ( +
+
+ +

No items found

+
+
+ +
+ }> +

Your inbox is empty

+
+
+ +
+ }> +

No results found

+

Try adjusting your search

+
+
+ +
+ }> +

No documents

+

Upload your first document

+ +
+
+
+); From 0fbeea91716999dd1ef138f3f8bca4c73a470d72 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 21:14:29 +0100 Subject: [PATCH 6/7] Remove bad commit --- .../decisions/ProfileUsersAccessTable.tsx | 257 ------------------ packages/ui/src/components/ui/table.tsx | 5 +- 2 files changed, 2 insertions(+), 260 deletions(-) delete mode 100644 apps/app/src/components/decisions/ProfileUsersAccessTable.tsx diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx deleted file mode 100644 index 7ec75ffbc..000000000 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'use client'; - -import { trpc } from '@op/api/client'; -import type { profileUserEncoder } from '@op/api/encoders'; -import { Button } from '@op/ui/Button'; -import { EmptyState } from '@op/ui/EmptyState'; -import { Select, SelectItem } from '@op/ui/Select'; -import { Skeleton } from '@op/ui/Skeleton'; -import { toast } from '@op/ui/Toast'; -import { - Table, - TableBody, - TableCell, - TableColumn, - TableHeader, - TableRow, -} from '@op/ui/ui/table'; -import { useEffect, useState } from 'react'; -import type { SortDescriptor } from 'react-aria-components'; -import type { z } from 'zod'; - -import { useTranslations } from '@/lib/i18n'; - -import { ProfileAvatar } from '@/components/ProfileAvatar'; - -// Infer the ProfileUser type from the encoder -type ProfileUser = z.infer; - -const getProfileUserStatus = (profileUser: ProfileUser): string => { - // Check for status field if available, otherwise derive from data - if ('status' in profileUser && typeof profileUser.status === 'string') { - // Capitalize first letter - return ( - profileUser.status.charAt(0).toUpperCase() + profileUser.status.slice(1) - ); - } - // Default to "Active" for existing profile users - return 'Active'; -}; - -const ProfileUserRoleSelect = ({ - profileUserId, - currentRoleId, - profileId, - roles, -}: { - profileUserId: string; - currentRoleId?: string; - profileId: string; - roles: { id: string; name: string }[]; -}) => { - const t = useTranslations(); - const utils = trpc.useUtils(); - - const updateRoles = trpc.profile.updateUserRoles.useMutation({ - onSuccess: () => { - toast.success({ message: t('Role updated successfully') }); - void utils.profile.listUsers.invalidate({ profileId }); - }, - onError: (error) => { - toast.error({ - message: error.message || t('Failed to update role'), - }); - }, - }); - - const handleRoleChange = (roleId: string) => { - if (roleId && roleId !== currentRoleId) { - updateRoles.mutate({ - profileUserId, - roleIds: [roleId], - }); - } - }; - - return ( - - ); -}; - -// Hook to detect client-side hydration (workaround for React Aria Table SSR issue) -// See: https://github.com/adobe/react-spectrum/issues/4870 -const useIsHydrated = () => { - const [isHydrated, setIsHydrated] = useState(false); - useEffect(() => { - setIsHydrated(true); - }, []); - return isHydrated; -}; - -// Inner table content component -const ProfileUsersAccessTableContent = ({ - profileUsers, - profileId, - sortDescriptor, - onSortChange, - isLoading, -}: { - profileUsers: ProfileUser[]; - profileId: string; - sortDescriptor: SortDescriptor; - onSortChange: (descriptor: SortDescriptor) => void; - isLoading: boolean; -}) => { - const t = useTranslations(); - const isHydrated = useIsHydrated(); - - // Fetch roles with regular query - const { - data: rolesData, - isPending: rolesPending, - isError: rolesError, - } = trpc.organization.getRoles.useQuery(); - - // Don't render table until after hydration due to React Aria SSR limitations - if (!isHydrated || rolesPending) { - return ; - } - - if (rolesError) { - return null; // Error handled by parent - } - - const roles = rolesData?.roles ?? []; - - return ( -
- {isLoading && ( -
- -
- )} - - - - {t('Name')} - - - {t('Email')} - - - {t('Role')} - - - - {profileUsers.map((profileUser) => { - const displayName = - profileUser.profile?.name || - profileUser.name || - (profileUser.email?.split('@')?.[0] ?? 'Unknown'); - const currentRole = profileUser.roles[0]; - const status = getProfileUserStatus(profileUser); - - return ( - - -
- -
- - {displayName} - - - {status} - -
-
-
- - - {profileUser.email} - - - - - -
- ); - })} -
-
-
- ); -}; - -// Exported component with loading and error states -export const ProfileUsersAccessTable = ({ - profileUsers, - profileId, - sortDescriptor, - onSortChange, - isLoading, - isError, - onRetry, -}: { - profileUsers: ProfileUser[]; - profileId: string; - sortDescriptor: SortDescriptor; - onSortChange: (descriptor: SortDescriptor) => void; - isLoading: boolean; - isError: boolean; - onRetry: () => void; -}) => { - const t = useTranslations(); - - if (isError) { - return ( - - {t('Members could not be loaded')} - - - ); - } - - return ( - - ); -}; diff --git a/packages/ui/src/components/ui/table.tsx b/packages/ui/src/components/ui/table.tsx index 5084666e0..913f9f45c 100644 --- a/packages/ui/src/components/ui/table.tsx +++ b/packages/ui/src/components/ui/table.tsx @@ -27,9 +27,8 @@ import { import { LuArrowDown } from 'react-icons/lu'; import { twJoin, twMerge } from 'tailwind-merge'; -import { cx } from '@/lib/primitive'; - -import { Checkbox } from '@/components/Checkbox'; +import { cx } from '../../lib/primitive'; +import { Checkbox } from '../Checkbox'; interface TableProps extends Omit { allowResize?: boolean; From 127657f79048981216399f83489b35c3381b9cd4 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 10:11:06 +0100 Subject: [PATCH 7/7] adjust default gap --- packages/ui/src/components/EmptyState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/EmptyState.tsx b/packages/ui/src/components/EmptyState.tsx index 8528fb421..b1f37967d 100644 --- a/packages/ui/src/components/EmptyState.tsx +++ b/packages/ui/src/components/EmptyState.tsx @@ -10,7 +10,7 @@ export const EmptyState = ({ }) => { return (
-
+
{icon ?? }