From c546f68b75b5afd585066787da981d02636c060e Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 21 Jan 2026 15:35:51 +0100 Subject: [PATCH 01/28] First pass on adding UI --- .../decisions/[slug]/members/loading.tsx | 20 ++ .../decisions/[slug]/members/page.tsx | 58 ++++++ .../decisions/DecisionMemberRoleSelect.tsx | 76 ++++++++ .../decisions/DecisionMembersHeader.tsx | 49 +++++ .../decisions/DecisionMembersPage.tsx | 112 +++++++++++ .../decisions/DecisionMembersTable.tsx | 180 ++++++++++++++++++ .../decisions/InviteDecisionMemberModal.tsx | 170 +++++++++++++++++ 7 files changed, 665 insertions(+) create mode 100644 apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx create mode 100644 apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx create mode 100644 apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx create mode 100644 apps/app/src/components/decisions/DecisionMembersHeader.tsx create mode 100644 apps/app/src/components/decisions/DecisionMembersPage.tsx create mode 100644 apps/app/src/components/decisions/DecisionMembersTable.tsx create mode 100644 apps/app/src/components/decisions/InviteDecisionMemberModal.tsx diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx new file mode 100644 index 000000000..f3756896e --- /dev/null +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from '@op/ui/Skeleton'; + +export default function Loading() { + return ( +
+
+ +
+
+
+
+ + +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx new file mode 100644 index 000000000..6f74c7f63 --- /dev/null +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -0,0 +1,58 @@ +import { createClient } from '@op/api/serverClient'; +import { Skeleton } from '@op/ui/Skeleton'; +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import { DecisionMembersHeader } from '@/components/decisions/DecisionMembersHeader'; +import { DecisionMembersPage } from '@/components/decisions/DecisionMembersPage'; + +const DecisionMembersContent = async ({ slug }: { slug: string }) => { + const client = await createClient(); + + const decisionProfile = await client.decision.getDecisionBySlug({ + slug, + }); + + if (!decisionProfile || !decisionProfile.processInstance) { + notFound(); + } + + const profileId = decisionProfile.id; + const ownerSlug = decisionProfile.processInstance.owner?.slug; + const decisionName = + decisionProfile.processInstance.process?.name || + decisionProfile.processInstance.name; + + if (!ownerSlug) { + notFound(); + } + + return ( +
+ +
+ }> + + +
+
+ ); +}; + +const MembersPage = async ({ + params, +}: { + params: Promise<{ slug: string }>; +}) => { + const { slug } = await params; + + return ; +}; + +export default MembersPage; diff --git a/apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx b/apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx new file mode 100644 index 000000000..99520de4f --- /dev/null +++ b/apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { Select, SelectItem } from '@op/ui/Select'; +import { toast } from '@op/ui/Toast'; +import { Suspense } from 'react'; + +import { useTranslations } from '@/lib/i18n'; + +const RoleSelectContent = ({ + memberId, + currentRoleId, + profileId, +}: { + memberId: string; + currentRoleId?: string; + profileId: string; +}) => { + const t = useTranslations(); + const utils = trpc.useUtils(); + + const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); + + 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: memberId, + roleIds: [roleId], + }); + } + }; + + return ( + + ); +}; + +export const DecisionMemberRoleSelect = (props: { + memberId: string; + currentRoleId?: string; + profileId: string; +}) => { + return ( + + } + > + + + ); +}; diff --git a/apps/app/src/components/decisions/DecisionMembersHeader.tsx b/apps/app/src/components/decisions/DecisionMembersHeader.tsx new file mode 100644 index 000000000..b46cb0c98 --- /dev/null +++ b/apps/app/src/components/decisions/DecisionMembersHeader.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Header1 } from '@op/ui/Header'; +import { LuArrowLeft } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; +import { Link } from '@/lib/i18n/routing'; + +import { LocaleChooser } from '../LocaleChooser'; +import { UserAvatarMenu } from '../SiteHeader'; + +export const DecisionMembersHeader = ({ + backTo, + title, +}: { + backTo: { + label?: string; + href: string; + }; + title: string; +}) => { + const t = useTranslations(); + return ( +
+
+ + + + {t('Back')} {backTo.label ? `${t('to')} ${backTo.label}` : ''} + + +
+ +
+ + {t(title)} + +
+ +
+ + +
+
+ ); +}; diff --git a/apps/app/src/components/decisions/DecisionMembersPage.tsx b/apps/app/src/components/decisions/DecisionMembersPage.tsx new file mode 100644 index 000000000..b4fcededf --- /dev/null +++ b/apps/app/src/components/decisions/DecisionMembersPage.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { pluralize } from '@/utils/pluralize'; +import { trpc } from '@op/api/client'; +import { Button } from '@op/ui/Button'; +import { SearchField } from '@op/ui/SearchField'; +import { useMemo, useState } from 'react'; +import { LuUserPlus, LuUsers } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import { DecisionMembersTable } from './DecisionMembersTable'; +import { InviteDecisionMemberModal } from './InviteDecisionMemberModal'; + +export const DecisionMembersPage = ({ profileId }: { profileId: string }) => { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + + const [members] = trpc.profile.listUsers.useSuspenseQuery({ + profileId, + }); + + // Filter members based on search query + const filteredMembers = useMemo(() => { + if (!searchQuery.trim()) { + return members; + } + + const query = searchQuery.toLowerCase(); + return members.filter((member) => { + const name = member.name?.toLowerCase() || ''; + const email = member.email.toLowerCase(); + return name.includes(query) || email.includes(query); + }); + }, [members, searchQuery]); + + if (!members || members.length === 0) { + return ( +
+
+

+ {t('Members')} +

+ +
+ +
+
+ +
+
+ {t('No members found')} +
+

+ {t('Invite members to collaborate on this decision.')} +

+
+ + +
+ ); + } + + return ( +
+
+
+

+ {members.length} {pluralize(t('member'), members.length)} +

+
+ +
+ + +
+
+ + + + +
+ ); +}; diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/DecisionMembersTable.tsx new file mode 100644 index 000000000..99d4ac60d --- /dev/null +++ b/apps/app/src/components/decisions/DecisionMembersTable.tsx @@ -0,0 +1,180 @@ +'use client'; + +import type { RouterOutput } from '@op/api/client'; +import { trpc } from '@op/api/client'; +import { Avatar } from '@op/ui/Avatar'; +import { IconButton } from '@op/ui/IconButton'; +import { Menu, MenuItem, MenuTrigger } from '@op/ui/Menu'; +import { Popover } from '@op/ui/Popover'; +import { toast } from '@op/ui/Toast'; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from '@op/ui/ui/table'; +import { LuEllipsis } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; +import { Link } from '@/lib/i18n/routing'; + +import { DecisionMemberRoleSelect } from './DecisionMemberRoleSelect'; + +// Infer the Member type from the tRPC router output +type Member = RouterOutput['profile']['listUsers'][number]; + +const MemberActionsMenu = ({ + member, + profileId, +}: { + member: Member; + profileId: string; +}) => { + const t = useTranslations(); + const utils = trpc.useUtils(); + + const removeMember = trpc.profile.removeUser.useMutation({ + onSuccess: () => { + toast.success({ message: t('Member removed successfully') }); + void utils.profile.listUsers.invalidate({ profileId }); + }, + onError: (error) => { + toast.error({ + message: error.message || t('Failed to remove member'), + }); + }, + }); + + const handleRemove = () => { + if (confirm(t('Are you sure you want to remove this member?'))) { + removeMember.mutate({ profileUserId: member.id }); + } + }; + + return ( + + + + + + + + {t('Remove member')} + + + + + ); +}; + +export const DecisionMembersTable = ({ + members, + profileId, +}: { + members: Member[]; + profileId: string; +}) => { + const t = useTranslations(); + + const columns = [ + { id: 'name', name: t('Name') }, + { id: 'email', name: t('Email') }, + { id: 'role', name: t('Role') }, + { id: 'actions', name: '' }, + ]; + + return ( +
+ + + {(column) => ( + + {column.name} + + )} + + + {(member) => { + const displayName = + member.profile?.name || member.name || member.email.split('@')[0]; + const profileSlug = member.profile?.slug; + const profileType = member.profile?.type; + const currentRole = member.roles[0]; + + return ( + + +
+ + {member.profile?.avatarImage && ( + {displayName + )} + +
+ {profileSlug ? ( + + {displayName} + + ) : ( + + {displayName} + + )} + {member.createdAt && ( + + {t('Joined')}{' '} + {new Date(member.createdAt).toLocaleDateString()} + + )} +
+
+
+ + {member.email} + + + + + + + +
+ ); + }} +
+
+
+ ); +}; diff --git a/apps/app/src/components/decisions/InviteDecisionMemberModal.tsx b/apps/app/src/components/decisions/InviteDecisionMemberModal.tsx new file mode 100644 index 000000000..2dc57000e --- /dev/null +++ b/apps/app/src/components/decisions/InviteDecisionMemberModal.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { Button } from '@op/ui/Button'; +import { DialogTrigger } from '@op/ui/Dialog'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; +import { Select, SelectItem } from '@op/ui/Select'; +import { TextField } from '@op/ui/TextField'; +import { toast } from '@op/ui/Toast'; +import { Suspense, useEffect, useState } from 'react'; + +import { useTranslations } from '@/lib/i18n'; + +const InviteForm = ({ + profileId, + onSuccess, +}: { + profileId: string; + onSuccess: () => void; +}) => { + const t = useTranslations(); + const utils = trpc.useUtils(); + + const [email, setEmail] = useState(''); + const [selectedRoleId, setSelectedRoleId] = useState(''); + const [personalMessage, setPersonalMessage] = useState(''); + + const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); + + // Set default role to Member on mount + useEffect(() => { + if (!selectedRoleId && rolesData.roles.length > 0) { + const memberRole = rolesData.roles.find( + (role) => role.name.toLowerCase() === 'member', + ); + const defaultRole = memberRole || rolesData.roles[0]; + if (defaultRole) { + setSelectedRoleId(defaultRole.id); + } + } + }, [selectedRoleId, rolesData.roles]); + + const inviteMember = trpc.profile.addUser.useMutation({ + onSuccess: () => { + toast.success({ message: t('Invitation sent successfully') }); + void utils.profile.listUsers.invalidate({ profileId }); + setEmail(''); + setPersonalMessage(''); + onSuccess(); + }, + onError: (error) => { + toast.error({ + message: error.message || t('Failed to send invitation'), + }); + }, + }); + + const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); + }; + + const handleSubmit = () => { + if (!email.trim()) { + toast.error({ message: t('Please enter an email address') }); + return; + } + + if (!isValidEmail(email)) { + toast.error({ message: t('Please enter a valid email address') }); + return; + } + + if (!selectedRoleId) { + toast.error({ message: t('Please select a role') }); + return; + } + + inviteMember.mutate({ + profileId, + inviteeEmail: email.trim(), + roleIdsToAssign: [selectedRoleId], + personalMessage: personalMessage.trim() || undefined, + }); + }; + + return ( + <> + +

+ {t('Invite someone to collaborate on this decision.')} +

+ + + + + + +
+ + + + + ); +}; + +export const InviteDecisionMemberModal = ({ + profileId, + isOpen, + onOpenChange, +}: { + profileId: string; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}) => { + const t = useTranslations(); + + return ( + + + {t('Invite member')} + +
{t('Loading...')}
+ + } + > + onOpenChange(false)} + /> +
+
+
+ ); +}; From a94dc0b2cc3e2394276dd181d8f2ebcdd284c7e6 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 15:28:26 +0100 Subject: [PATCH 02/28] Add ProfileAvatar, update spacings on Roles page --- .../src/components/ProfileAvatar/index.tsx | 62 ++++++ .../decisions/DecisionMembersPage.tsx | 60 +++--- .../decisions/DecisionMembersTable.tsx | 197 +++++------------- 3 files changed, 145 insertions(+), 174 deletions(-) create mode 100644 apps/app/src/components/ProfileAvatar/index.tsx diff --git a/apps/app/src/components/ProfileAvatar/index.tsx b/apps/app/src/components/ProfileAvatar/index.tsx new file mode 100644 index 000000000..d5f8e7ba3 --- /dev/null +++ b/apps/app/src/components/ProfileAvatar/index.tsx @@ -0,0 +1,62 @@ +import { getPublicUrl } from '@/utils'; +import { Avatar, AvatarSkeleton } from '@op/ui/Avatar'; +import { cn } from '@op/ui/utils'; +import Image from 'next/image'; + +import { Link } from '@/lib/i18n'; + +type ProfileAvatarProps = { + profile?: { + name?: string | null; + slug?: string | null; + avatarImage?: { name?: string | null } | null; + } | null; + withLink?: boolean; + className?: string; +}; + +export const ProfileAvatar = ({ + profile, + withLink = true, + className, +}: ProfileAvatarProps) => { + if (!profile) { + return null; + } + + const name = profile?.name ?? ''; + const avatarImage = profile?.avatarImage; + const slug = profile?.slug; + + const avatar = ( + + {avatarImage?.name ? ( + {name + ) : null} + + ); + + return withLink && slug ? ( + + {avatar} + + ) : ( + avatar + ); +}; + +export const ProfileAvatarSkeleton = ({ + className, +}: { + className?: string; +}) => { + return ; +}; diff --git a/apps/app/src/components/decisions/DecisionMembersPage.tsx b/apps/app/src/components/decisions/DecisionMembersPage.tsx index b4fcededf..50ca107fc 100644 --- a/apps/app/src/components/decisions/DecisionMembersPage.tsx +++ b/apps/app/src/components/decisions/DecisionMembersPage.tsx @@ -1,6 +1,5 @@ 'use client'; -import { pluralize } from '@/utils/pluralize'; import { trpc } from '@op/api/client'; import { Button } from '@op/ui/Button'; import { SearchField } from '@op/ui/SearchField'; @@ -37,11 +36,18 @@ export const DecisionMembersPage = ({ profileId }: { profileId: string }) => { if (!members || members.length === 0) { return ( -
+
+

+ {t('Members')} +

+
-

- {t('Members')} -

+
-
+
@@ -74,30 +80,26 @@ export const DecisionMembersPage = ({ profileId }: { profileId: string }) => { } return ( -
-
-
-

- {members.length} {pluralize(t('member'), members.length)} -

-
+
+

+ {t('Members')} +

-
- - -
+
+ +
diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/DecisionMembersTable.tsx index 99d4ac60d..539fd30b0 100644 --- a/apps/app/src/components/decisions/DecisionMembersTable.tsx +++ b/apps/app/src/components/decisions/DecisionMembersTable.tsx @@ -1,12 +1,6 @@ 'use client'; import type { RouterOutput } from '@op/api/client'; -import { trpc } from '@op/api/client'; -import { Avatar } from '@op/ui/Avatar'; -import { IconButton } from '@op/ui/IconButton'; -import { Menu, MenuItem, MenuTrigger } from '@op/ui/Menu'; -import { Popover } from '@op/ui/Popover'; -import { toast } from '@op/ui/Toast'; import { Table, TableBody, @@ -15,66 +9,23 @@ import { TableHeader, TableRow, } from '@op/ui/ui/table'; -import { LuEllipsis } from 'react-icons/lu'; +import { ProfileAvatar } from '@/components/ProfileAvatar'; import { useTranslations } from '@/lib/i18n'; -import { Link } from '@/lib/i18n/routing'; import { DecisionMemberRoleSelect } from './DecisionMemberRoleSelect'; // Infer the Member type from the tRPC router output type Member = RouterOutput['profile']['listUsers'][number]; -const MemberActionsMenu = ({ - member, - profileId, -}: { - member: Member; - profileId: string; -}) => { - const t = useTranslations(); - const utils = trpc.useUtils(); - - const removeMember = trpc.profile.removeUser.useMutation({ - onSuccess: () => { - toast.success({ message: t('Member removed successfully') }); - void utils.profile.listUsers.invalidate({ profileId }); - }, - onError: (error) => { - toast.error({ - message: error.message || t('Failed to remove member'), - }); - }, - }); - - const handleRemove = () => { - if (confirm(t('Are you sure you want to remove this member?'))) { - removeMember.mutate({ profileUserId: member.id }); - } - }; - - return ( - - - - - - - - {t('Remove member')} - - - - - ); +const getMemberStatus = (member: Member): string => { + // Check for status field if available, otherwise derive from data + if ('status' in member && typeof member.status === 'string') { + // Capitalize first letter + return member.status.charAt(0).toUpperCase() + member.status.slice(1); + } + // Default to "Active" for existing members + return 'Active'; }; export const DecisionMembersTable = ({ @@ -86,95 +37,51 @@ export const DecisionMembersTable = ({ }) => { const t = useTranslations(); - const columns = [ - { id: 'name', name: t('Name') }, - { id: 'email', name: t('Email') }, - { id: 'role', name: t('Role') }, - { id: 'actions', name: '' }, - ]; - return ( -
- - - {(column) => ( - - {column.name} - - )} - - - {(member) => { - const displayName = - member.profile?.name || member.name || member.email.split('@')[0]; - const profileSlug = member.profile?.slug; - const profileType = member.profile?.type; - const currentRole = member.roles[0]; +
+ + + {t('Name')} + + {t('Email')} + {t('Role')} + + + {members.map((member) => { + const displayName = + member.profile?.name || member.name || member.email.split('@')[0]; + const currentRole = member.roles[0]; + const status = getMemberStatus(member); - return ( - - -
- - {member.profile?.avatarImage && ( - {displayName - )} - -
- {profileSlug ? ( - - {displayName} - - ) : ( - - {displayName} - - )} - {member.createdAt && ( - - {t('Joined')}{' '} - {new Date(member.createdAt).toLocaleDateString()} - - )} -
+ return ( + + +
+ +
+ + {displayName} + + {status}
- - - {member.email} - - - - - - - - - ); - }} - -
-
+
+ + + + {member.email} + + + + + + + ); + })} + + ); }; From f62e76ccaf11af3b39e15375b2a9daaa8c6c87e8 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 18:43:29 +0100 Subject: [PATCH 03/28] Update datatable for members to avoid SSR issues --- .../decisions/DecisionMemberRoleSelect.tsx | 76 ------------ .../decisions/DecisionMembersTable.tsx | 112 +++++++++++++++++- 2 files changed, 108 insertions(+), 80 deletions(-) delete mode 100644 apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx diff --git a/apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx b/apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx deleted file mode 100644 index 99520de4f..000000000 --- a/apps/app/src/components/decisions/DecisionMemberRoleSelect.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { trpc } from '@op/api/client'; -import { Select, SelectItem } from '@op/ui/Select'; -import { toast } from '@op/ui/Toast'; -import { Suspense } from 'react'; - -import { useTranslations } from '@/lib/i18n'; - -const RoleSelectContent = ({ - memberId, - currentRoleId, - profileId, -}: { - memberId: string; - currentRoleId?: string; - profileId: string; -}) => { - const t = useTranslations(); - const utils = trpc.useUtils(); - - const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); - - 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: memberId, - roleIds: [roleId], - }); - } - }; - - return ( - - ); -}; - -export const DecisionMemberRoleSelect = (props: { - memberId: string; - currentRoleId?: string; - profileId: string; -}) => { - return ( - - } - > - - - ); -}; diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/DecisionMembersTable.tsx index 539fd30b0..0c5f31cec 100644 --- a/apps/app/src/components/decisions/DecisionMembersTable.tsx +++ b/apps/app/src/components/decisions/DecisionMembersTable.tsx @@ -1,6 +1,11 @@ 'use client'; +import { APIErrorBoundary } from '@/utils/APIErrorBoundary'; import type { RouterOutput } from '@op/api/client'; +import { trpc } from '@op/api/client'; +import { Select, SelectItem } from '@op/ui/Select'; +import { Skeleton } from '@op/ui/Skeleton'; +import { toast } from '@op/ui/Toast'; import { Table, TableBody, @@ -9,11 +14,11 @@ import { TableHeader, TableRow, } from '@op/ui/ui/table'; +import { Suspense, useEffect, useState } from 'react'; -import { ProfileAvatar } from '@/components/ProfileAvatar'; import { useTranslations } from '@/lib/i18n'; -import { DecisionMemberRoleSelect } from './DecisionMemberRoleSelect'; +import { ProfileAvatar } from '@/components/ProfileAvatar'; // Infer the Member type from the tRPC router output type Member = RouterOutput['profile']['listUsers'][number]; @@ -28,7 +33,70 @@ const getMemberStatus = (member: Member): string => { return 'Active'; }; -export const DecisionMembersTable = ({ +const MemberRoleSelect = ({ + memberId, + currentRoleId, + profileId, + roles, +}: { + memberId: 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: memberId, + 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 component that uses suspense query - Suspense boundary is OUTSIDE the table +const DecisionMembersTableContent = ({ members, profileId, }: { @@ -36,6 +104,15 @@ export const DecisionMembersTable = ({ profileId: string; }) => { const t = useTranslations(); + const isHydrated = useIsHydrated(); + + // Fetch roles once at table level with suspense - boundary is outside this component + const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); + + // Don't render table until after hydration due to React Aria SSR limitations + if (!isHydrated) { + return ; + } return ( @@ -72,10 +149,11 @@ export const DecisionMembersTable = ({ - @@ -85,3 +163,29 @@ export const DecisionMembersTable = ({
); }; + +const RolesLoadError = () => { + const t = useTranslations(); + return ( +
+ {t('Unable to load roles')} +
+ ); +}; + +// Exported component wraps with Suspense + ErrorBoundary OUTSIDE the table +export const DecisionMembersTable = ({ + members, + profileId, +}: { + members: Member[]; + profileId: string; +}) => { + return ( + }}> + }> + + + + ); +}; From a0f3bab5258ea236e89d20f3f746034c5c5259ca Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 19:01:49 +0100 Subject: [PATCH 04/28] Allow for retries from APIErrorBoundary --- apps/app/src/utils/APIErrorBoundary.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/app/src/utils/APIErrorBoundary.tsx b/apps/app/src/utils/APIErrorBoundary.tsx index 7e3c8e47c..2e00c3df7 100644 --- a/apps/app/src/utils/APIErrorBoundary.tsx +++ b/apps/app/src/utils/APIErrorBoundary.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactElement, ReactNode } from 'react'; +import { cloneElement, ReactElement, ReactNode } from 'react'; import { FallbackProps, ErrorBoundary as ReactErrorBoundary, @@ -19,16 +19,16 @@ export const APIErrorBoundary = ({ }) => { return ( { + fallbackRender={({ error, resetErrorBoundary }: FallbackProps) => { const fallback = fallbacks[error.data?.httpStatus]; if (fallback) { - return fallback; + return cloneElement(fallback, { resetErrorBoundary }); } // support a default fallback if (fallbacks['default']) { - return fallbacks['default']; + return cloneElement(fallbacks['default'], { resetErrorBoundary }); } throw error; From f4b7bd5683bd53e5e3dd0750a4e8c696d31d2bd0 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 19:25:37 +0100 Subject: [PATCH 05/28] Allow retry on error, style error --- .../decisions/DecisionMembersTable.tsx | 20 ++++++++++++++++--- apps/app/src/lib/i18n/dictionaries/bn.json | 4 +++- apps/app/src/lib/i18n/dictionaries/en.json | 4 +++- apps/app/src/lib/i18n/dictionaries/es.json | 4 +++- apps/app/src/lib/i18n/dictionaries/fr.json | 4 +++- apps/app/src/lib/i18n/dictionaries/pt.json | 4 +++- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/DecisionMembersTable.tsx index 0c5f31cec..26fc1d14d 100644 --- a/apps/app/src/components/decisions/DecisionMembersTable.tsx +++ b/apps/app/src/components/decisions/DecisionMembersTable.tsx @@ -3,6 +3,7 @@ import { APIErrorBoundary } from '@/utils/APIErrorBoundary'; import type { RouterOutput } from '@op/api/client'; import { trpc } from '@op/api/client'; +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'; @@ -15,6 +16,7 @@ import { TableRow, } from '@op/ui/ui/table'; import { Suspense, useEffect, useState } from 'react'; +import { LuCircleAlert } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -164,11 +166,23 @@ const DecisionMembersTableContent = ({ ); }; -const RolesLoadError = () => { +const RolesLoadError = ({ + resetErrorBoundary, +}: { + resetErrorBoundary?: () => void; +}) => { const t = useTranslations(); return ( -
- {t('Unable to load roles')} +
+
+
+ +
+ {t('Roles could not be loaded')} + +
); }; diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index ae8340944..be20c5381 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -437,5 +437,7 @@ "User avatar": "ব্যবহারকারীর অবতার", "Done": "সম্পন্ন", "How do you want to structure your decision-making process?": "আপনি কীভাবে আপনার সিদ্ধান্ত গ্রহণ প্রক্রিয়া গঠন করতে চান?", - "No templates found": "কোনো টেমপ্লেট পাওয়া যায়নি" + "No templates found": "কোনো টেমপ্লেট পাওয়া যায়নি", + "Roles could not be loaded": "ভূমিকাগুলি লোড করা যায়নি", + "Try again": "আবার চেষ্টা করুন" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 6dd83eeb0..249d26665 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -437,5 +437,7 @@ "User avatar": "User avatar", "Done": "Done", "How do you want to structure your decision-making process?": "How do you want to structure your decision-making process?", - "No templates found": "No templates found" + "No templates found": "No templates found", + "Roles could not be loaded": "Roles could not be loaded", + "Try again": "Try again" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 3bc51293f..ce61efc3f 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -436,5 +436,7 @@ "User avatar": "Avatar del usuario", "Done": "Listo", "How do you want to structure your decision-making process?": "¿Cómo quieres estructurar tu proceso de toma de decisiones?", - "No templates found": "No se encontraron plantillas" + "No templates found": "No se encontraron plantillas", + "Roles could not be loaded": "No se pudieron cargar los roles", + "Try again": "Intentar de nuevo" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index d1acc507d..f1c54d280 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -437,5 +437,7 @@ "User avatar": "Avatar de l'utilisateur", "Done": "Terminé", "How do you want to structure your decision-making process?": "Comment souhaitez-vous structurer votre processus de prise de décision ?", - "No templates found": "Aucun modèle trouvé" + "No templates found": "Aucun modèle trouvé", + "Roles could not be loaded": "Les rôles n'ont pas pu être chargés", + "Try again": "Réessayer" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 63543c6fe..2942f7498 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -437,5 +437,7 @@ "User avatar": "Avatar do usuário", "Done": "Concluído", "How do you want to structure your decision-making process?": "Como você deseja estruturar seu processo de tomada de decisão?", - "No templates found": "Nenhum modelo encontrado" + "No templates found": "Nenhum modelo encontrado", + "Roles could not be loaded": "Não foi possível carregar os papéis", + "Try again": "Tentar novamente" } From b2f8b4631b4cbd9d783ed801637cf61135341f63 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 20:06:47 +0100 Subject: [PATCH 06/28] Update size of dropdown --- apps/app/src/components/decisions/DecisionMembersTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/DecisionMembersTable.tsx index 26fc1d14d..8f80770a4 100644 --- a/apps/app/src/components/decisions/DecisionMembersTable.tsx +++ b/apps/app/src/components/decisions/DecisionMembersTable.tsx @@ -76,6 +76,7 @@ const MemberRoleSelect = ({ selectedKey={currentRoleId || ''} onSelectionChange={(key) => handleRoleChange(key as string)} isDisabled={updateRoles.isPending} + size="small" className="w-32" > {roles.map((role) => ( From f71cbe45b60528ce463a2b03e65a343afde01b81 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 20:15:02 +0100 Subject: [PATCH 07/28] Enable local sortin --- .../decisions/DecisionMembersTable.tsx | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/DecisionMembersTable.tsx index 8f80770a4..59fdf55e6 100644 --- a/apps/app/src/components/decisions/DecisionMembersTable.tsx +++ b/apps/app/src/components/decisions/DecisionMembersTable.tsx @@ -15,7 +15,8 @@ import { TableHeader, TableRow, } from '@op/ui/ui/table'; -import { Suspense, useEffect, useState } from 'react'; +import type { SortDescriptor } from 'react-aria-components'; +import { Suspense, useEffect, useMemo, useState } from 'react'; import { LuCircleAlert } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -108,26 +109,68 @@ const DecisionMembersTableContent = ({ }) => { const t = useTranslations(); const isHydrated = useIsHydrated(); + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'name', + direction: 'ascending', + }); // Fetch roles once at table level with suspense - boundary is outside this component const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); + const sortedMembers = useMemo(() => { + return [...members].sort((a, b) => { + let aValue: string; + let bValue: string; + + switch (sortDescriptor.column) { + case 'name': + aValue = a.profile?.name || a.name || a.email.split('@')[0] || ''; + bValue = b.profile?.name || b.name || b.email.split('@')[0] || ''; + break; + case 'email': + aValue = a.email || ''; + bValue = b.email || ''; + break; + case 'role': + aValue = a.roles[0]?.name || ''; + bValue = b.roles[0]?.name || ''; + break; + default: + return 0; + } + + const comparison = aValue.localeCompare(bValue); + return sortDescriptor.direction === 'descending' + ? -comparison + : comparison; + }); + }, [members, sortDescriptor]); + // Don't render table until after hydration due to React Aria SSR limitations if (!isHydrated) { return ; } return ( - +
- + {t('Name')} - {t('Email')} - {t('Role')} + + {t('Email')} + + + {t('Role')} + - {members.map((member) => { + {sortedMembers.map((member) => { const displayName = member.profile?.name || member.name || member.email.split('@')[0]; const currentRole = member.roles[0]; From d367c12b7b66ad0e68353cda3f88675c9fbbd0c5 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 22:08:38 +0100 Subject: [PATCH 08/28] Switch to useQuery --- .../decisions/[slug]/members/page.tsx | 6 +-- .../decisions/DecisionMembersPage.tsx | 50 ++----------------- apps/app/src/utils/APIErrorBoundary.tsx | 2 +- 3 files changed, 6 insertions(+), 52 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx index 6f74c7f63..1fe250aa4 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -1,7 +1,5 @@ import { createClient } from '@op/api/serverClient'; -import { Skeleton } from '@op/ui/Skeleton'; import { notFound } from 'next/navigation'; -import { Suspense } from 'react'; import { DecisionMembersHeader } from '@/components/decisions/DecisionMembersHeader'; import { DecisionMembersPage } from '@/components/decisions/DecisionMembersPage'; @@ -37,9 +35,7 @@ const DecisionMembersContent = async ({ slug }: { slug: string }) => { title="Members" />
- }> - - +
); diff --git a/apps/app/src/components/decisions/DecisionMembersPage.tsx b/apps/app/src/components/decisions/DecisionMembersPage.tsx index 50ca107fc..e5e0cbcd7 100644 --- a/apps/app/src/components/decisions/DecisionMembersPage.tsx +++ b/apps/app/src/components/decisions/DecisionMembersPage.tsx @@ -4,7 +4,7 @@ import { trpc } from '@op/api/client'; import { Button } from '@op/ui/Button'; import { SearchField } from '@op/ui/SearchField'; import { useMemo, useState } from 'react'; -import { LuUserPlus, LuUsers } from 'react-icons/lu'; +import { LuUserPlus } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -22,6 +22,9 @@ export const DecisionMembersPage = ({ profileId }: { profileId: string }) => { // Filter members based on search query const filteredMembers = useMemo(() => { + if (!members) { + return []; + } if (!searchQuery.trim()) { return members; } @@ -34,51 +37,6 @@ export const DecisionMembersPage = ({ profileId }: { profileId: string }) => { }); }, [members, searchQuery]); - if (!members || members.length === 0) { - return ( -
-

- {t('Members')} -

- -
- - -
- -
-
- -
-
- {t('No members found')} -
-

- {t('Invite members to collaborate on this decision.')} -

-
- - -
- ); - } - return (

diff --git a/apps/app/src/utils/APIErrorBoundary.tsx b/apps/app/src/utils/APIErrorBoundary.tsx index 2e00c3df7..9106125e7 100644 --- a/apps/app/src/utils/APIErrorBoundary.tsx +++ b/apps/app/src/utils/APIErrorBoundary.tsx @@ -1,6 +1,6 @@ 'use client'; -import { cloneElement, ReactElement, ReactNode } from 'react'; +import { ReactElement, ReactNode, cloneElement } from 'react'; import { FallbackProps, ErrorBoundary as ReactErrorBoundary, From bc6f06d5bad59556f358f71b818d1334d510a743 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 22:32:16 +0100 Subject: [PATCH 09/28] Correct variant on button --- apps/app/src/components/decisions/DecisionMembersPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/components/decisions/DecisionMembersPage.tsx b/apps/app/src/components/decisions/DecisionMembersPage.tsx index e5e0cbcd7..0e25d993e 100644 --- a/apps/app/src/components/decisions/DecisionMembersPage.tsx +++ b/apps/app/src/components/decisions/DecisionMembersPage.tsx @@ -53,6 +53,7 @@ export const DecisionMembersPage = ({ profileId }: { profileId: string }) => {

-
- - -
+ - -
); }; From bb3369892ebb33105264e35a383f6cf7ce7e13ad Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 22 Jan 2026 22:39:50 +0100 Subject: [PATCH 12/28] Remove temp invite decision modal --- .../decisions/InviteDecisionMemberModal.tsx | 170 ------------------ 1 file changed, 170 deletions(-) delete mode 100644 apps/app/src/components/decisions/InviteDecisionMemberModal.tsx diff --git a/apps/app/src/components/decisions/InviteDecisionMemberModal.tsx b/apps/app/src/components/decisions/InviteDecisionMemberModal.tsx deleted file mode 100644 index 2dc57000e..000000000 --- a/apps/app/src/components/decisions/InviteDecisionMemberModal.tsx +++ /dev/null @@ -1,170 +0,0 @@ -'use client'; - -import { trpc } from '@op/api/client'; -import { Button } from '@op/ui/Button'; -import { DialogTrigger } from '@op/ui/Dialog'; -import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; -import { Select, SelectItem } from '@op/ui/Select'; -import { TextField } from '@op/ui/TextField'; -import { toast } from '@op/ui/Toast'; -import { Suspense, useEffect, useState } from 'react'; - -import { useTranslations } from '@/lib/i18n'; - -const InviteForm = ({ - profileId, - onSuccess, -}: { - profileId: string; - onSuccess: () => void; -}) => { - const t = useTranslations(); - const utils = trpc.useUtils(); - - const [email, setEmail] = useState(''); - const [selectedRoleId, setSelectedRoleId] = useState(''); - const [personalMessage, setPersonalMessage] = useState(''); - - const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); - - // Set default role to Member on mount - useEffect(() => { - if (!selectedRoleId && rolesData.roles.length > 0) { - const memberRole = rolesData.roles.find( - (role) => role.name.toLowerCase() === 'member', - ); - const defaultRole = memberRole || rolesData.roles[0]; - if (defaultRole) { - setSelectedRoleId(defaultRole.id); - } - } - }, [selectedRoleId, rolesData.roles]); - - const inviteMember = trpc.profile.addUser.useMutation({ - onSuccess: () => { - toast.success({ message: t('Invitation sent successfully') }); - void utils.profile.listUsers.invalidate({ profileId }); - setEmail(''); - setPersonalMessage(''); - onSuccess(); - }, - onError: (error) => { - toast.error({ - message: error.message || t('Failed to send invitation'), - }); - }, - }); - - const isValidEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email.trim()); - }; - - const handleSubmit = () => { - if (!email.trim()) { - toast.error({ message: t('Please enter an email address') }); - return; - } - - if (!isValidEmail(email)) { - toast.error({ message: t('Please enter a valid email address') }); - return; - } - - if (!selectedRoleId) { - toast.error({ message: t('Please select a role') }); - return; - } - - inviteMember.mutate({ - profileId, - inviteeEmail: email.trim(), - roleIdsToAssign: [selectedRoleId], - personalMessage: personalMessage.trim() || undefined, - }); - }; - - return ( - <> - -

- {t('Invite someone to collaborate on this decision.')} -

- - - - - - -
- - - - - ); -}; - -export const InviteDecisionMemberModal = ({ - profileId, - isOpen, - onOpenChange, -}: { - profileId: string; - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; -}) => { - const t = useTranslations(); - - return ( - - - {t('Invite member')} - -
{t('Loading...')}
- - } - > - onOpenChange(false)} - /> -
-
-
- ); -}; From c3e5a657666524eeb079a4e99e0099fcaee674a6 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 00:07:20 +0100 Subject: [PATCH 13/28] Change name to be more generic --- .../decisions/[slug]/members/page.tsx | 8 +- .../decisions/DecisionMembersPage.tsx | 52 -------- ...eader.tsx => ProfileUsersAccessHeader.tsx} | 2 +- .../decisions/ProfileUsersAccessPage.tsx | 91 ++++++++++++++ ...sTable.tsx => ProfileUsersAccessTable.tsx} | 114 ++++++++---------- 5 files changed, 147 insertions(+), 120 deletions(-) delete mode 100644 apps/app/src/components/decisions/DecisionMembersPage.tsx rename apps/app/src/components/decisions/{DecisionMembersHeader.tsx => ProfileUsersAccessHeader.tsx} (96%) create mode 100644 apps/app/src/components/decisions/ProfileUsersAccessPage.tsx rename apps/app/src/components/decisions/{DecisionMembersTable.tsx => ProfileUsersAccessTable.tsx} (69%) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx index 1fe250aa4..999d7782f 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -1,8 +1,8 @@ import { createClient } from '@op/api/serverClient'; import { notFound } from 'next/navigation'; -import { DecisionMembersHeader } from '@/components/decisions/DecisionMembersHeader'; -import { DecisionMembersPage } from '@/components/decisions/DecisionMembersPage'; +import { ProfileUsersAccessHeader } from '@/components/decisions/ProfileUsersAccessHeader'; +import { ProfileUsersAccessPage } from '@/components/decisions/ProfileUsersAccessPage'; const DecisionMembersContent = async ({ slug }: { slug: string }) => { const client = await createClient(); @@ -27,7 +27,7 @@ const DecisionMembersContent = async ({ slug }: { slug: string }) => { return (
- { title="Members" />
- +
); diff --git a/apps/app/src/components/decisions/DecisionMembersPage.tsx b/apps/app/src/components/decisions/DecisionMembersPage.tsx deleted file mode 100644 index 8dce427be..000000000 --- a/apps/app/src/components/decisions/DecisionMembersPage.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { trpc } from '@op/api/client'; -import { SearchField } from '@op/ui/SearchField'; -import { useMemo, useState } from 'react'; - -import { useTranslations } from '@/lib/i18n'; - -import { DecisionMembersTable } from './DecisionMembersTable'; - -export const DecisionMembersPage = ({ profileId }: { profileId: string }) => { - const t = useTranslations(); - const [searchQuery, setSearchQuery] = useState(''); - - const [members] = trpc.profile.listUsers.useSuspenseQuery({ - profileId, - }); - - // Filter members based on search query - const filteredMembers = useMemo(() => { - if (!members) { - return []; - } - if (!searchQuery.trim()) { - return members; - } - - const query = searchQuery.toLowerCase(); - return members.filter((member) => { - const name = member.name?.toLowerCase() || ''; - const email = member.email.toLowerCase(); - return name.includes(query) || email.includes(query); - }); - }, [members, searchQuery]); - - return ( -
-

- {t('Members')} -

- - - - -
- ); -}; diff --git a/apps/app/src/components/decisions/DecisionMembersHeader.tsx b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx similarity index 96% rename from apps/app/src/components/decisions/DecisionMembersHeader.tsx rename to apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx index b46cb0c98..d9425ee83 100644 --- a/apps/app/src/components/decisions/DecisionMembersHeader.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx @@ -9,7 +9,7 @@ import { Link } from '@/lib/i18n/routing'; import { LocaleChooser } from '../LocaleChooser'; import { UserAvatarMenu } from '../SiteHeader'; -export const DecisionMembersHeader = ({ +export const ProfileUsersAccessHeader = ({ backTo, title, }: { diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx new file mode 100644 index 000000000..a449747a3 --- /dev/null +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -0,0 +1,91 @@ +'use client'; + +import type { RouterInput } from '@op/api/client'; +import { trpc } from '@op/api/client'; +import { useCursorPagination, useDebounce } from '@op/hooks'; +import { Pagination } from '@op/ui/Pagination'; +import { SearchField } from '@op/ui/SearchField'; +import { useEffect, useState } from 'react'; +import type { SortDescriptor } from 'react-aria-components'; + +import { useTranslations } from '@/lib/i18n'; + +import { ProfileUsersAccessTable } from './ProfileUsersAccessTable'; + +type ListUsersInput = RouterInput['profile']['listUsers']; +type SortColumn = NonNullable; +type SortDirection = NonNullable; + +const ITEMS_PER_PAGE = 25; + +export const ProfileUsersAccessPage = ({ profileId }: { profileId: string }) => { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedQuery] = useDebounce(searchQuery, 200); + + // Sorting state + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'name', + direction: 'ascending', + }); + + // Cursor pagination + const { cursor, handleNext, handlePrevious, canGoPrevious, reset } = + useCursorPagination(ITEMS_PER_PAGE); + + // Reset pagination when search or sort changes + useEffect(() => { + reset(); + }, [debouncedQuery, sortDescriptor.column, sortDescriptor.direction]); + + // Convert React Aria sort descriptor to API format + const orderBy = sortDescriptor.column as SortColumn; + const dir: SortDirection = + sortDescriptor.direction === 'ascending' ? 'asc' : 'desc'; + + // Build query input - only include query if >= 2 chars (API requirement) + const queryInput: ListUsersInput = { + profileId, + cursor, + limit: ITEMS_PER_PAGE, + orderBy, + dir, + query: debouncedQuery.length >= 2 ? debouncedQuery : undefined, + }; + + const [data] = trpc.profile.listUsers.useSuspenseQuery(queryInput); + const { items: profileUsers, next, hasMore } = data; + + const onNext = () => { + if (hasMore && next) { + handleNext(next); + } + }; + + return ( +
+

+ {t('Members')} +

+ + + + + + +
+ ); +}; diff --git a/apps/app/src/components/decisions/DecisionMembersTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx similarity index 69% rename from apps/app/src/components/decisions/DecisionMembersTable.tsx rename to apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index 59fdf55e6..7dca8db8c 100644 --- a/apps/app/src/components/decisions/DecisionMembersTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -1,8 +1,7 @@ 'use client'; -import { APIErrorBoundary } from '@/utils/APIErrorBoundary'; -import type { RouterOutput } from '@op/api/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'; @@ -15,34 +14,38 @@ import { TableHeader, TableRow, } from '@op/ui/ui/table'; +import { Suspense, useEffect, useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; -import { Suspense, useEffect, useMemo, useState } from 'react'; import { LuCircleAlert } from 'react-icons/lu'; +import type { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; import { ProfileAvatar } from '@/components/ProfileAvatar'; +import { APIErrorBoundary } from '@/utils/APIErrorBoundary'; -// Infer the Member type from the tRPC router output -type Member = RouterOutput['profile']['listUsers'][number]; +// Infer the ProfileUser type from the encoder +type ProfileUser = z.infer; -const getMemberStatus = (member: Member): string => { +const getProfileUserStatus = (profileUser: ProfileUser): string => { // Check for status field if available, otherwise derive from data - if ('status' in member && typeof member.status === 'string') { + if ('status' in profileUser && typeof profileUser.status === 'string') { // Capitalize first letter - return member.status.charAt(0).toUpperCase() + member.status.slice(1); + return ( + profileUser.status.charAt(0).toUpperCase() + profileUser.status.slice(1) + ); } - // Default to "Active" for existing members + // Default to "Active" for existing profile users return 'Active'; }; -const MemberRoleSelect = ({ - memberId, +const ProfileUserRoleSelect = ({ + profileUserId, currentRoleId, profileId, roles, }: { - memberId: string; + profileUserId: string; currentRoleId?: string; profileId: string; roles: { id: string; name: string }[]; @@ -65,7 +68,7 @@ const MemberRoleSelect = ({ const handleRoleChange = (roleId: string) => { if (roleId && roleId !== currentRoleId) { updateRoles.mutate({ - profileUserId: memberId, + profileUserId, roleIds: [roleId], }); } @@ -100,52 +103,23 @@ const useIsHydrated = () => { }; // Inner component that uses suspense query - Suspense boundary is OUTSIDE the table -const DecisionMembersTableContent = ({ - members, +const ProfileUsersAccessTableContent = ({ + profileUsers, profileId, + sortDescriptor, + onSortChange, }: { - members: Member[]; + profileUsers: ProfileUser[]; profileId: string; + sortDescriptor: SortDescriptor; + onSortChange: (descriptor: SortDescriptor) => void; }) => { const t = useTranslations(); const isHydrated = useIsHydrated(); - const [sortDescriptor, setSortDescriptor] = useState({ - column: 'name', - direction: 'ascending', - }); // Fetch roles once at table level with suspense - boundary is outside this component const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); - const sortedMembers = useMemo(() => { - return [...members].sort((a, b) => { - let aValue: string; - let bValue: string; - - switch (sortDescriptor.column) { - case 'name': - aValue = a.profile?.name || a.name || a.email.split('@')[0] || ''; - bValue = b.profile?.name || b.name || b.email.split('@')[0] || ''; - break; - case 'email': - aValue = a.email || ''; - bValue = b.email || ''; - break; - case 'role': - aValue = a.roles[0]?.name || ''; - bValue = b.roles[0]?.name || ''; - break; - default: - return 0; - } - - const comparison = aValue.localeCompare(bValue); - return sortDescriptor.direction === 'descending' - ? -comparison - : comparison; - }); - }, [members, sortDescriptor]); - // Don't render table until after hydration due to React Aria SSR limitations if (!isHydrated) { return ; @@ -156,7 +130,7 @@ const DecisionMembersTableContent = ({ aria-label={t('Members list')} className="w-full table-fixed" sortDescriptor={sortDescriptor} - onSortChange={setSortDescriptor} + onSortChange={onSortChange} > @@ -170,17 +144,22 @@ const DecisionMembersTableContent = ({ - {sortedMembers.map((member) => { + {profileUsers.map((profileUser) => { const displayName = - member.profile?.name || member.name || member.email.split('@')[0]; - const currentRole = member.roles[0]; - const status = getMemberStatus(member); + profileUser.profile?.name || + profileUser.name || + profileUser.email.split('@')[0]; + const currentRole = profileUser.roles[0]; + const status = getProfileUserStatus(profileUser); return ( - +
- +
{displayName} @@ -191,12 +170,12 @@ const DecisionMembersTableContent = ({ - {member.email} + {profileUser.email} - void; }) => { return ( }}> }> - + ); From 1efa57e4ca1faed1f3b3c0720bd6950e59d4f521 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 00:22:48 +0100 Subject: [PATCH 14/28] Update query --- .../decisions/ProfileUsersAccessPage.tsx | 18 +- .../decisions/ProfileUsersAccessTable.tsx | 194 ++++++++++-------- apps/app/src/lib/i18n/dictionaries/bn.json | 2 +- apps/app/src/lib/i18n/dictionaries/en.json | 2 +- apps/app/src/lib/i18n/dictionaries/es.json | 2 +- apps/app/src/lib/i18n/dictionaries/fr.json | 2 +- apps/app/src/lib/i18n/dictionaries/pt.json | 2 +- 7 files changed, 131 insertions(+), 91 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx index a449747a3..7a50182ce 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -18,7 +18,11 @@ type SortDirection = NonNullable; const ITEMS_PER_PAGE = 25; -export const ProfileUsersAccessPage = ({ profileId }: { profileId: string }) => { +export const ProfileUsersAccessPage = ({ + profileId, +}: { + profileId: string; +}) => { const t = useTranslations(); const [searchQuery, setSearchQuery] = useState(''); const [debouncedQuery] = useDebounce(searchQuery, 200); @@ -53,8 +57,13 @@ export const ProfileUsersAccessPage = ({ profileId }: { profileId: string }) => query: debouncedQuery.length >= 2 ? debouncedQuery : undefined, }; - const [data] = trpc.profile.listUsers.useSuspenseQuery(queryInput); - const { items: profileUsers, next, hasMore } = data; + // Use regular query - cache handles exact query matches, loading shown for uncached queries + const { data, isPending, isError, refetch } = + trpc.profile.listUsers.useQuery(queryInput); + + const profileUsers = data?.items ?? []; + const next = data?.next; + const hasMore = data?.hasMore ?? false; const onNext = () => { if (hasMore && next) { @@ -80,6 +89,9 @@ export const ProfileUsersAccessPage = ({ profileId }: { profileId: string }) => profileId={profileId} sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} + isLoading={isPending} + isError={isError} + onRetry={() => void refetch()} /> ; @@ -102,98 +101,120 @@ const useIsHydrated = () => { return isHydrated; }; -// Inner component that uses suspense query - Suspense boundary is OUTSIDE the table +// 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 once at table level with suspense - boundary is outside this component - const [rolesData] = trpc.organization.getRoles.useSuspenseQuery(); + // 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) { + if (!isHydrated || rolesPending) { return ; } + if (rolesError) { + return null; // Error handled by parent + } + + const roles = rolesData?.roles ?? []; + return ( -
- - - {t('Name')} - - - {t('Email')} - - - {t('Role')} - - - - {profileUsers.map((profileUser) => { - const displayName = - profileUser.profile?.name || - profileUser.name || - profileUser.email.split('@')[0]; - const currentRole = profileUser.roles[0]; - const status = getProfileUserStatus(profileUser); - - return ( - - -
- -
- - {displayName} - - {status} +
+ {isLoading && ( +
+ +
+ )} +
+ + + {t('Name')} + + + {t('Email')} + + + {t('Role')} + + + + {profileUsers.map((profileUser) => { + const displayName = + profileUser.profile?.name || + profileUser.name || + profileUser.email.split('@')[0]; + const currentRole = profileUser.roles[0]; + const status = getProfileUserStatus(profileUser); + + return ( + + +
+ +
+ + {displayName} + + + {status} + +
- -
- - - {profileUser.email} - - - - - -
- ); - })} -
-
+ + + + {profileUser.email} + + + + + + + ); + })} + + +
); }; -const RolesLoadError = ({ - resetErrorBoundary, -}: { - resetErrorBoundary?: () => void; -}) => { +const MembersLoadError = ({ onRetry }: { onRetry: () => void }) => { const t = useTranslations(); return (
@@ -201,8 +222,8 @@ const RolesLoadError = ({
- {t('Roles could not be loaded')} -
@@ -210,28 +231,35 @@ const RolesLoadError = ({ ); }; -// Exported component wraps with Suspense + ErrorBoundary OUTSIDE the table +// 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; }) => { + if (isError) { + return ; + } + return ( - }}> - }> - - - + ); }; diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index be20c5381..779a9c9aa 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -438,6 +438,6 @@ "Done": "সম্পন্ন", "How do you want to structure your decision-making process?": "আপনি কীভাবে আপনার সিদ্ধান্ত গ্রহণ প্রক্রিয়া গঠন করতে চান?", "No templates found": "কোনো টেমপ্লেট পাওয়া যায়নি", - "Roles could not be loaded": "ভূমিকাগুলি লোড করা যায়নি", + "Members could not be loaded": "সদস্যদের লোড করা যায়নি", "Try again": "আবার চেষ্টা করুন" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 249d26665..3594745f7 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -438,6 +438,6 @@ "Done": "Done", "How do you want to structure your decision-making process?": "How do you want to structure your decision-making process?", "No templates found": "No templates found", - "Roles could not be loaded": "Roles could not be loaded", + "Members could not be loaded": "Members could not be loaded", "Try again": "Try again" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index ce61efc3f..4257cfb88 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -437,6 +437,6 @@ "Done": "Listo", "How do you want to structure your decision-making process?": "¿Cómo quieres estructurar tu proceso de toma de decisiones?", "No templates found": "No se encontraron plantillas", - "Roles could not be loaded": "No se pudieron cargar los roles", + "Members could not be loaded": "No se pudieron cargar los miembros", "Try again": "Intentar de nuevo" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index f1c54d280..831ed60a3 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -438,6 +438,6 @@ "Done": "Terminé", "How do you want to structure your decision-making process?": "Comment souhaitez-vous structurer votre processus de prise de décision ?", "No templates found": "Aucun modèle trouvé", - "Roles could not be loaded": "Les rôles n'ont pas pu être chargés", + "Members could not be loaded": "Les membres n'ont pas pu être chargés", "Try again": "Réessayer" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 2942f7498..5668f13db 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -438,6 +438,6 @@ "Done": "Concluído", "How do you want to structure your decision-making process?": "Como você deseja estruturar seu processo de tomada de decisão?", "No templates found": "Nenhum modelo encontrado", - "Roles could not be loaded": "Não foi possível carregar os papéis", + "Members could not be loaded": "Não foi possível carregar os membros", "Try again": "Tentar novamente" } From 353fe871e00636f492faec5bfd955305e1efd9c8 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 11:04:10 +0100 Subject: [PATCH 15/28] Small fixes to email and standard tailwind sizing, add translations --- apps/app/src/components/decisions/ProfileUsersAccessPage.tsx | 3 ++- .../app/src/components/decisions/ProfileUsersAccessTable.tsx | 2 +- apps/app/src/lib/i18n/dictionaries/bn.json | 5 ++++- apps/app/src/lib/i18n/dictionaries/en.json | 5 ++++- apps/app/src/lib/i18n/dictionaries/es.json | 5 ++++- apps/app/src/lib/i18n/dictionaries/fr.json | 5 ++++- apps/app/src/lib/i18n/dictionaries/pt.json | 5 ++++- 7 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx index 7a50182ce..a52567f1a 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -38,6 +38,7 @@ export const ProfileUsersAccessPage = ({ useCursorPagination(ITEMS_PER_PAGE); // Reset pagination when search or sort changes + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset is intentionally excluded to avoid infinite loop useEffect(() => { reset(); }, [debouncedQuery, sortDescriptor.column, sortDescriptor.direction]); @@ -81,7 +82,7 @@ export const ProfileUsersAccessPage = ({ placeholder={t('Search')} value={searchQuery} onChange={setSearchQuery} - className="w-[368px]" + className="w-full max-w-96" /> Date: Fri, 23 Jan 2026 11:18:57 +0100 Subject: [PATCH 16/28] Update naming on ProfileMembersContent --- .../[locale]/(no-header)/decisions/[slug]/members/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx index 999d7782f..69fdb29fa 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'; import { ProfileUsersAccessHeader } from '@/components/decisions/ProfileUsersAccessHeader'; import { ProfileUsersAccessPage } from '@/components/decisions/ProfileUsersAccessPage'; -const DecisionMembersContent = async ({ slug }: { slug: string }) => { +const ProfileMembersContent = async ({ slug }: { slug: string }) => { const client = await createClient(); const decisionProfile = await client.decision.getDecisionBySlug({ @@ -48,7 +48,7 @@ const MembersPage = async ({ }) => { const { slug } = await params; - return ; + return ; }; export default MembersPage; From 80f0de7b2fd3a55f734f2372f0652e371c65afcf Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 11:23:36 +0100 Subject: [PATCH 17/28] Don't translate a string passed in --- .../[locale]/(no-header)/decisions/[slug]/members/page.tsx | 3 ++- .../src/components/decisions/ProfileUsersAccessHeader.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx index 69fdb29fa..4fec9d2e2 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; import { ProfileUsersAccessHeader } from '@/components/decisions/ProfileUsersAccessHeader'; import { ProfileUsersAccessPage } from '@/components/decisions/ProfileUsersAccessPage'; +import { TranslatedText } from '@/components/TranslatedText'; const ProfileMembersContent = async ({ slug }: { slug: string }) => { const client = await createClient(); @@ -32,7 +33,7 @@ const ProfileMembersContent = async ({ slug }: { slug: string }) => { label: decisionName, href: `/decisions/${slug}`, }} - title="Members" + title={} />
diff --git a/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx index d9425ee83..cef1afcac 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx @@ -1,5 +1,7 @@ 'use client'; +import type { ReactNode } from 'react'; + import { Header1 } from '@op/ui/Header'; import { LuArrowLeft } from 'react-icons/lu'; @@ -17,7 +19,7 @@ export const ProfileUsersAccessHeader = ({ label?: string; href: string; }; - title: string; + title: ReactNode; }) => { const t = useTranslations(); return ( @@ -36,7 +38,7 @@ export const ProfileUsersAccessHeader = ({
- {t(title)} + {title}
From e7e9807c42877e074644ef2e18956f3f213070c5 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:28:31 +0100 Subject: [PATCH 18/28] Use encoder types --- .../components/decisions/ProfileUsersAccessPage.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx index a52567f1a..1ec3d5f97 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -1,7 +1,7 @@ 'use client'; -import type { RouterInput } from '@op/api/client'; import { trpc } from '@op/api/client'; +import type { SortDir } from '@op/common'; import { useCursorPagination, useDebounce } from '@op/hooks'; import { Pagination } from '@op/ui/Pagination'; import { SearchField } from '@op/ui/SearchField'; @@ -12,9 +12,8 @@ import { useTranslations } from '@/lib/i18n'; import { ProfileUsersAccessTable } from './ProfileUsersAccessTable'; -type ListUsersInput = RouterInput['profile']['listUsers']; -type SortColumn = NonNullable; -type SortDirection = NonNullable; +// Sort columns supported by profile.listUsers endpoint +type SortColumn = 'name' | 'email' | 'role'; const ITEMS_PER_PAGE = 25; @@ -45,11 +44,11 @@ export const ProfileUsersAccessPage = ({ // Convert React Aria sort descriptor to API format const orderBy = sortDescriptor.column as SortColumn; - const dir: SortDirection = + const dir: SortDir = sortDescriptor.direction === 'ascending' ? 'asc' : 'desc'; // Build query input - only include query if >= 2 chars (API requirement) - const queryInput: ListUsersInput = { + const queryInput = { profileId, cursor, limit: ITEMS_PER_PAGE, From 065d4b1e340c9f0b7c4c4f8fe33bb1ea39bb8e17 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:35:06 +0100 Subject: [PATCH 19/28] format --- .../app/[locale]/(no-header)/decisions/[slug]/members/page.tsx | 2 +- apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx index 4fec9d2e2..a11926d67 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -1,9 +1,9 @@ import { createClient } from '@op/api/serverClient'; import { notFound } from 'next/navigation'; +import { TranslatedText } from '@/components/TranslatedText'; import { ProfileUsersAccessHeader } from '@/components/decisions/ProfileUsersAccessHeader'; import { ProfileUsersAccessPage } from '@/components/decisions/ProfileUsersAccessPage'; -import { TranslatedText } from '@/components/TranslatedText'; const ProfileMembersContent = async ({ slug }: { slug: string }) => { const client = await createClient(); diff --git a/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx index cef1afcac..4c7ba21ac 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx @@ -1,8 +1,7 @@ 'use client'; -import type { ReactNode } from 'react'; - import { Header1 } from '@op/ui/Header'; +import type { ReactNode } from 'react'; import { LuArrowLeft } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; From dbbe48c933b40db5c3b07cd257097d7cd85e914e Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 23 Jan 2026 20:57:07 +0100 Subject: [PATCH 20/28] Convert other EmptyStates to new component --- .../decisions/ProfileUsersAccessTable.tsx | 30 +++++++------------ .../components/decisions/ProposalsList.tsx | 1 + .../decisions/pages/ResultsPage.tsx | 3 ++ .../decisions/pages/StandardDecisionPage.tsx | 3 ++ 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index dcb8c4e1c..517df112a 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -16,11 +16,11 @@ import { } from '@op/ui/ui/table'; import { useEffect, useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; -import { LuCircleAlert } from 'react-icons/lu'; 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 @@ -214,23 +214,6 @@ const ProfileUsersAccessTableContent = ({ ); }; -const MembersLoadError = ({ onRetry }: { onRetry: () => void }) => { - const t = useTranslations(); - return ( -
-
-
- -
- {t('Members could not be loaded')} - -
-
- ); -}; - // Exported component with loading and error states export const ProfileUsersAccessTable = ({ profileUsers, @@ -249,8 +232,17 @@ export const ProfileUsersAccessTable = ({ isError: boolean; onRetry: () => void; }) => { + const t = useTranslations(); + if (isError) { - return ; + 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 384526cdc..4d40d54fe 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -13,6 +13,7 @@ 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/pages/ResultsPage.tsx b/apps/app/src/components/decisions/pages/ResultsPage.tsx index 2ca04fa86..59675ea3e 100644 --- a/apps/app/src/components/decisions/pages/ResultsPage.tsx +++ b/apps/app/src/components/decisions/pages/ResultsPage.tsx @@ -13,6 +13,9 @@ 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 e45c5c74a..224b89114 100644 --- a/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx +++ b/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx @@ -10,6 +10,9 @@ 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'; From d8cfaff6fbab0cea5e41901788ea0b72c87c8142 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 12:03:58 +0100 Subject: [PATCH 21/28] format --- apps/app/src/components/decisions/ProfileUsersAccessTable.tsx | 2 +- apps/app/src/components/decisions/ProposalsList.tsx | 1 - apps/app/src/components/decisions/pages/ResultsPage.tsx | 3 --- .../src/components/decisions/pages/StandardDecisionPage.tsx | 3 --- 4 files changed, 1 insertion(+), 8 deletions(-) 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 4d40d54fe..384526cdc 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -13,7 +13,6 @@ 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/pages/ResultsPage.tsx b/apps/app/src/components/decisions/pages/ResultsPage.tsx index 59675ea3e..2ca04fa86 100644 --- a/apps/app/src/components/decisions/pages/ResultsPage.tsx +++ b/apps/app/src/components/decisions/pages/ResultsPage.tsx @@ -13,9 +13,6 @@ 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 224b89114..e45c5c74a 100644 --- a/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx +++ b/apps/app/src/components/decisions/pages/StandardDecisionPage.tsx @@ -10,9 +10,6 @@ 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'; From ae0f808fdfef4ee8a6c4bd4bec0ee574ea5d372b Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 12:24:26 +0100 Subject: [PATCH 22/28] Optimize a bit --- .../decisions/[slug]/members/error.tsx | 7 +++++ .../src/components/ProfileAvatar/index.tsx | 6 ++-- .../decisions/ProfileUsersAccessPage.tsx | 11 +++++-- .../decisions/ProfileUsersAccessTable.tsx | 29 ++++++++++--------- packages/hooks/src/useCursorPagination.ts | 6 ++-- 5 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx new file mode 100644 index 000000000..3a1cbd3bc --- /dev/null +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx @@ -0,0 +1,7 @@ +'use client'; + +import PageError, { ErrorProps } from '@/components/screens/PageError'; + +export default function Error(props: ErrorProps) { + return ; +} diff --git a/apps/app/src/components/ProfileAvatar/index.tsx b/apps/app/src/components/ProfileAvatar/index.tsx index d5f8e7ba3..5063328bd 100644 --- a/apps/app/src/components/ProfileAvatar/index.tsx +++ b/apps/app/src/components/ProfileAvatar/index.tsx @@ -24,9 +24,9 @@ export const ProfileAvatar = ({ return null; } - const name = profile?.name ?? ''; - const avatarImage = profile?.avatarImage; - const slug = profile?.slug; + const name = profile.name ?? ''; + const avatarImage = profile.avatarImage; + const slug = profile.slug; const avatar = ( { reset(); - }, [debouncedQuery, sortDescriptor.column, sortDescriptor.direction]); + }, [debouncedQuery, sortDescriptor.column, sortDescriptor.direction, reset]); // Convert React Aria sort descriptor to API format const orderBy = sortDescriptor.column as SortColumn; @@ -61,9 +60,14 @@ export const ProfileUsersAccessPage = ({ const { data, isPending, isError, refetch } = trpc.profile.listUsers.useQuery(queryInput); + // Fetch roles in parallel to avoid waterfall loading + const { data: rolesData, isPending: rolesPending } = + trpc.organization.getRoles.useQuery(); + const profileUsers = data?.items ?? []; const next = data?.next; const hasMore = data?.hasMore ?? false; + const roles = rolesData?.roles ?? []; const onNext = () => { if (hasMore && next) { @@ -89,9 +93,10 @@ export const ProfileUsersAccessPage = ({ profileId={profileId} sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} - isLoading={isPending} + isLoading={isPending || rolesPending} isError={isError} onRetry={() => void refetch()} + roles={roles} /> void; isLoading: boolean; + roles: { id: string; name: string }[]; }) => { 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) { + if (!isHydrated) { return ; } - if (rolesError) { - return null; // Error handled by parent - } - - const roles = rolesData?.roles ?? []; - return (
{isLoading && ( @@ -223,6 +213,7 @@ export const ProfileUsersAccessTable = ({ isLoading, isError, onRetry, + roles, }: { profileUsers: ProfileUser[]; profileId: string; @@ -231,6 +222,7 @@ export const ProfileUsersAccessTable = ({ isLoading: boolean; isError: boolean; onRetry: () => void; + roles: { id: string; name: string }[]; }) => { const t = useTranslations(); @@ -245,6 +237,14 @@ export const ProfileUsersAccessTable = ({ ); } + if (profileUsers.length === 0 && !isLoading) { + return ( + }> + {t('No members found')} + + ); + } + return ( ); }; diff --git a/packages/hooks/src/useCursorPagination.ts b/packages/hooks/src/useCursorPagination.ts index 8ef945a29..a74257cda 100644 --- a/packages/hooks/src/useCursorPagination.ts +++ b/packages/hooks/src/useCursorPagination.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; /** * Custom hook for managing cursor-based pagination state. @@ -55,10 +55,10 @@ export function useCursorPagination(limit: number) { * Reset pagination to the first page. * Useful when filters or search queries change. */ - const reset = () => { + const reset = useCallback(() => { setCursor(null); setCursorHistory([null]); - }; + }, []); return { cursor, From 7a4651233b9dff293b14475ad5f04a956dbb2c1f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 12:32:50 +0100 Subject: [PATCH 23/28] Use standard tailwind calc --- packages/styles/shared-styles.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/styles/shared-styles.css b/packages/styles/shared-styles.css index 50827c7f4..d4fe25221 100644 --- a/packages/styles/shared-styles.css +++ b/packages/styles/shared-styles.css @@ -320,7 +320,7 @@ --radius-lg: 0.375rem; --radius-xs: calc(var(--radius-lg) * 0.25); --radius-sm: calc(var(--radius-lg) * 0.5); - --radius-md: 0.5rem; + --radius-md: calc(var(--radius-lg) * 1.33); --radius-xl: calc(var(--radius-lg) * 1.5); --radius-2xl: calc(var(--radius-lg) * 2); --radius-3xl: calc(var(--radius-lg) * 3); @@ -614,6 +614,7 @@ 100% { transform: rotate(-5deg); } + 50% { transform: rotate(5deg); } @@ -624,6 +625,7 @@ 100% { background-position: 0% 0%; } + 50% { background-position: 100% 100%; } From bd08cd7060bd9b658788d98f59702837b9b0b9c0 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 12:38:36 +0100 Subject: [PATCH 24/28] Use render props for APIErrorBoundary --- apps/app/src/components/decisions/pages/ResultsPage.tsx | 4 ++-- apps/app/src/utils/APIErrorBoundary.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/decisions/pages/ResultsPage.tsx b/apps/app/src/components/decisions/pages/ResultsPage.tsx index 2ca04fa86..e471d6309 100644 --- a/apps/app/src/components/decisions/pages/ResultsPage.tsx +++ b/apps/app/src/components/decisions/pages/ResultsPage.tsx @@ -146,7 +146,7 @@ function ResultsPageContent({ ( }> {t('Results are still being processed.')} @@ -169,7 +169,7 @@ function ResultsPageContent({ , + default: () => , }} >
diff --git a/apps/app/src/utils/APIErrorBoundary.tsx b/apps/app/src/utils/APIErrorBoundary.tsx index 9106125e7..a58c54339 100644 --- a/apps/app/src/utils/APIErrorBoundary.tsx +++ b/apps/app/src/utils/APIErrorBoundary.tsx @@ -1,13 +1,13 @@ 'use client'; -import { ReactElement, ReactNode, cloneElement } from 'react'; +import { ReactNode } from 'react'; import { FallbackProps, ErrorBoundary as ReactErrorBoundary, } from 'react-error-boundary'; type APIFallbacks = { - [code: string]: ReactElement | null; + [code: string]: ((props: FallbackProps) => ReactNode) | null; }; export const APIErrorBoundary = ({ @@ -23,12 +23,12 @@ export const APIErrorBoundary = ({ const fallback = fallbacks[error.data?.httpStatus]; if (fallback) { - return cloneElement(fallback, { resetErrorBoundary }); + return fallback({ error, resetErrorBoundary }); } // support a default fallback if (fallbacks['default']) { - return cloneElement(fallbacks['default'], { resetErrorBoundary }); + return fallbacks['default']({ error, resetErrorBoundary }); } throw error; From 908c87eb8b3d5ccff4798dda8129566dc6bc21d0 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 12:44:01 +0100 Subject: [PATCH 25/28] Remove hasMore --- apps/app/src/components/decisions/ProfileUsersAccessPage.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx index c75cc5abb..90f59dad0 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -66,11 +66,10 @@ export const ProfileUsersAccessPage = ({ const profileUsers = data?.items ?? []; const next = data?.next; - const hasMore = data?.hasMore ?? false; const roles = rolesData?.roles ?? []; const onNext = () => { - if (hasMore && next) { + if (next) { handleNext(next); } }; @@ -100,7 +99,7 @@ export const ProfileUsersAccessPage = ({ />
From 608a9b849192ff489eb56f2dd41c98199d36a637 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 13:07:49 +0100 Subject: [PATCH 26/28] Export ProfileUser type, formatting --- .../decisions/[slug]/members/page.tsx | 6 +- .../decisions/ProfileUsersAccessPage.tsx | 9 +- .../decisions/ProfileUsersAccessTable.tsx | 116 ++++++++---------- services/api/src/encoders/profiles.ts | 2 + 4 files changed, 61 insertions(+), 72 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx index a11926d67..847ae7948 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -17,10 +17,8 @@ const ProfileMembersContent = async ({ slug }: { slug: string }) => { } const profileId = decisionProfile.id; - const ownerSlug = decisionProfile.processInstance.owner?.slug; - const decisionName = - decisionProfile.processInstance.process?.name || - decisionProfile.processInstance.name; + const ownerSlug = decisionProfile.processInstance.owner?.slug; // will exist for all new processes + const decisionName = decisionProfile.processInstance.name; if (!ownerSlug) { notFound(); diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx index 90f59dad0..bc3f7f397 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -46,7 +46,7 @@ export const ProfileUsersAccessPage = ({ const dir: SortDir = sortDescriptor.direction === 'ascending' ? 'asc' : 'desc'; - // Build query input - only include query if >= 2 chars (API requirement) + // Build query input - only include query if >= 2 chars const queryInput = { profileId, cursor, @@ -56,7 +56,7 @@ export const ProfileUsersAccessPage = ({ query: debouncedQuery.length >= 2 ? debouncedQuery : undefined, }; - // Use regular query - cache handles exact query matches, loading shown for uncached queries + // Use regular query - cache handles exact query matches, loading shown for uncached queries. We don't use a Suspense due to not wanting to suspend the entire table const { data, isPending, isError, refetch } = trpc.profile.listUsers.useQuery(queryInput); @@ -64,9 +64,8 @@ export const ProfileUsersAccessPage = ({ const { data: rolesData, isPending: rolesPending } = trpc.organization.getRoles.useQuery(); - const profileUsers = data?.items ?? []; - const next = data?.next; - const roles = rolesData?.roles ?? []; + const { items: profileUsers = [], next } = data ?? {}; + const { roles = [] } = rolesData ?? {}; const onNext = () => { if (next) { diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index d62870eda..ac6794bd6 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -1,7 +1,7 @@ 'use client'; import { trpc } from '@op/api/client'; -import type { profileUserEncoder } from '@op/api/encoders'; +import type { ProfileUser } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; import { EmptyState } from '@op/ui/EmptyState'; import { Select, SelectItem } from '@op/ui/Select'; @@ -18,23 +18,66 @@ import { import { useEffect, useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; import { LuUsers } from 'react-icons/lu'; -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; +// Exported component with loading and error states +export const ProfileUsersAccessTable = ({ + profileUsers, + profileId, + sortDescriptor, + onSortChange, + isLoading, + isError, + onRetry, + roles, +}: { + profileUsers: ProfileUser[]; + profileId: string; + sortDescriptor: SortDescriptor; + onSortChange: (descriptor: SortDescriptor) => void; + isLoading: boolean; + isError: boolean; + onRetry: () => void; + roles: { id: string; name: string }[]; +}) => { + const t = useTranslations(); -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 + if (isError) { return ( - profileUser.status.charAt(0).toUpperCase() + profileUser.status.slice(1) + + {t('Members could not be loaded')} + + ); } + + if (profileUsers.length === 0 && !isLoading) { + return ( + }> + {t('No members found')} + + ); + } + + return ( + + ); +}; + +const getProfileUserStatus = (): string => { + // TODO: We need this logic in the backend // Default to "Active" for existing profile users return 'Active'; }; @@ -162,7 +205,7 @@ const ProfileUsersAccessTableContent = ({ profileUser.name || (profileUser.email?.split('@')?.[0] ?? 'Unknown'); const currentRole = profileUser.roles[0]; - const status = getProfileUserStatus(profileUser); + const status = getProfileUserStatus(); return ( @@ -203,56 +246,3 @@ const ProfileUsersAccessTableContent = ({
); }; - -// Exported component with loading and error states -export const ProfileUsersAccessTable = ({ - profileUsers, - profileId, - sortDescriptor, - onSortChange, - isLoading, - isError, - onRetry, - roles, -}: { - profileUsers: ProfileUser[]; - profileId: string; - sortDescriptor: SortDescriptor; - onSortChange: (descriptor: SortDescriptor) => void; - isLoading: boolean; - isError: boolean; - onRetry: () => void; - roles: { id: string; name: string }[]; -}) => { - const t = useTranslations(); - - if (isError) { - return ( - - {t('Members could not be loaded')} - - - ); - } - - if (profileUsers.length === 0 && !isLoading) { - return ( - }> - {t('No members found')} - - ); - } - - return ( - - ); -}; diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts index 3ce29b72c..256650c30 100644 --- a/services/api/src/encoders/profiles.ts +++ b/services/api/src/encoders/profiles.ts @@ -70,3 +70,5 @@ export const profileUserEncoder = createSelectSchema(profileUsers).extend({ // Roles using shared minimal encoder roles: z.array(accessRoleMinimalEncoder), }); + +export type ProfileUser = z.infer; From 22b1bac0a7b11ed4fe69993d3e7b73d6f2d2f596 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 13:09:04 +0100 Subject: [PATCH 27/28] Use ClientOnly for table --- .../decisions/ProfileUsersAccessTable.tsx | 170 +++++++++--------- apps/app/src/utils/ClientOnly.tsx | 10 +- 2 files changed, 88 insertions(+), 92 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index ac6794bd6..f878f9146 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -15,11 +15,11 @@ import { TableHeader, TableRow, } from '@op/ui/ui/table'; -import { useEffect, useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; import { LuUsers } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; +import { ClientOnly } from '@/utils/ClientOnly'; import { ProfileAvatar } from '@/components/ProfileAvatar'; @@ -135,16 +135,6 @@ const ProfileUserRoleSelect = ({ ); }; -// 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, @@ -162,87 +152,87 @@ const ProfileUsersAccessTableContent = ({ roles: { id: string; name: string }[]; }) => { const t = useTranslations(); - const isHydrated = useIsHydrated(); - - // Don't render table until after hydration due to React Aria SSR limitations - if (!isHydrated) { - return ; - } 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(); - - return ( - - -
- -
- - {displayName} - - - {status} - + }> +
+ {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(); + + return ( + + +
+ +
+ + {displayName} + + + {status} + +
- -
- - - {profileUser.email} - - - - - -
- ); - })} -
-
-
+ + + + {profileUser.email} + + + + + + + ); + })} + + +
+ ); }; diff --git a/apps/app/src/utils/ClientOnly.tsx b/apps/app/src/utils/ClientOnly.tsx index 44b731990..8ab2c4fac 100644 --- a/apps/app/src/utils/ClientOnly.tsx +++ b/apps/app/src/utils/ClientOnly.tsx @@ -3,7 +3,13 @@ import { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; -export const ClientOnly = ({ children }: { children: ReactNode }) => { +export const ClientOnly = ({ + children, + fallback = null, +}: { + children: ReactNode; + fallback?: ReactNode; +}) => { const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -11,7 +17,7 @@ export const ClientOnly = ({ children }: { children: ReactNode }) => { }, []); if (!isMounted) { - return
; + return <>{fallback}; } return <>{children}; From beae2d17f80cb2e4c1375c0b9eff6fc16fee0e57 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 26 Jan 2026 13:16:08 +0100 Subject: [PATCH 28/28] Small fixes --- .../decisions/ProfileUsersAccessTable.tsx | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index f878f9146..4a5614395 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ClientOnly } from '@/utils/ClientOnly'; import { trpc } from '@op/api/client'; import type { ProfileUser } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; @@ -19,7 +20,6 @@ import type { SortDescriptor } from 'react-aria-components'; import { LuUsers } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -import { ClientOnly } from '@/utils/ClientOnly'; import { ProfileAvatar } from '@/components/ProfileAvatar'; @@ -168,22 +168,13 @@ const ProfileUsersAccessTableContent = ({ onSortChange={onSortChange} > - + {t('Name')} {t('Email')} - + {t('Role')} @@ -200,10 +191,7 @@ const ProfileUsersAccessTableContent = ({
- +
{displayName}