Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c546f68
First pass on adding UI
scazan Jan 21, 2026
a94dc0b
Add ProfileAvatar, update spacings on Roles page
scazan Jan 22, 2026
f62e76c
Update datatable for members to avoid SSR issues
scazan Jan 22, 2026
a0f3bab
Allow for retries from APIErrorBoundary
scazan Jan 22, 2026
f4b7bd5
Allow retry on error, style error
scazan Jan 22, 2026
b2f8b46
Update size of dropdown
scazan Jan 22, 2026
f71cbe4
Enable local sortin
scazan Jan 22, 2026
d367c12
Switch to useQuery
scazan Jan 22, 2026
bc6f06d
Correct variant on button
scazan Jan 22, 2026
1ae67d2
Fix radius-md
scazan Jan 22, 2026
026301e
Remove invite button for now
scazan Jan 22, 2026
bb33698
Remove temp invite decision modal
scazan Jan 22, 2026
c3e5a65
Change name to be more generic
scazan Jan 22, 2026
1efa57e
Update query
scazan Jan 22, 2026
353fe87
Small fixes to email and standard tailwind sizing, add translations
scazan Jan 23, 2026
cf73306
Update naming on ProfileMembersContent
scazan Jan 23, 2026
80f0de7
Don't translate a string passed in
scazan Jan 23, 2026
e7e9807
Use encoder types
scazan Jan 23, 2026
065d4b1
format
scazan Jan 23, 2026
dbbe48c
Convert other EmptyStates to new component
scazan Jan 23, 2026
d8cfaff
format
scazan Jan 26, 2026
ae0f808
Optimize a bit
scazan Jan 26, 2026
7a46512
Use standard tailwind calc
scazan Jan 26, 2026
bd08cd7
Use render props for APIErrorBoundary
scazan Jan 26, 2026
908c87e
Remove hasMore
scazan Jan 26, 2026
608a9b8
Export ProfileUser type, formatting
scazan Jan 26, 2026
22b1bac
Use ClientOnly for table
scazan Jan 26, 2026
beae2d1
Small fixes
scazan Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';

import PageError, { ErrorProps } from '@/components/screens/PageError';

export default function Error(props: ErrorProps) {
return <PageError {...props} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Skeleton } from '@op/ui/Skeleton';

export default function Loading() {
return (
<div className="min-h-screen bg-neutral-offWhite">
<div className="border-b bg-white p-2 px-6 md:py-3">
<Skeleton className="h-8 w-48" />
</div>
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-10 w-24" />
</div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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';

const ProfileMembersContent = 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; // will exist for all new processes
const decisionName = decisionProfile.processInstance.name;

if (!ownerSlug) {
notFound();
}

return (
<div className="min-h-screen bg-neutral-offWhite">
<ProfileUsersAccessHeader
backTo={{
label: decisionName,
href: `/decisions/${slug}`,
}}
title={<TranslatedText text="Members" />}
/>
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<ProfileUsersAccessPage profileId={profileId} />
</div>
</div>
);
};

const MembersPage = async ({
params,
}: {
params: Promise<{ slug: string }>;
}) => {
const { slug } = await params;

return <ProfileMembersContent slug={slug} />;
};

export default MembersPage;
62 changes: 62 additions & 0 deletions apps/app/src/components/ProfileAvatar/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<Avatar
className={cn('size-6', withLink && 'hover:opacity-80', className)}
placeholder={name ?? ''}
>
{avatarImage?.name ? (
<Image
src={getPublicUrl(avatarImage?.name) ?? ''}
alt={name ?? ''}
fill
className="object-cover"
/>
) : null}
</Avatar>
);

return withLink && slug ? (
<Link href={`/profile/${slug}`} className="hover:no-underline">
{avatar}
</Link>
) : (
avatar
);
};

export const ProfileAvatarSkeleton = ({
className,
}: {
className?: string;
}) => {
return <AvatarSkeleton className={cn('size-6', className)} />;
};
50 changes: 50 additions & 0 deletions apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import { Header1 } from '@op/ui/Header';
import type { ReactNode } from 'react';
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 ProfileUsersAccessHeader = ({
backTo,
title,
}: {
backTo: {
label?: string;
href: string;
};
title: ReactNode;
}) => {
const t = useTranslations();
return (
<header className="grid grid-cols-[auto_1fr_auto] items-center border-b bg-white p-2 px-6 sm:grid-cols-3 md:py-3">
<div className="flex items-center gap-3">
<Link
href={backTo.href}
className="flex items-center gap-2 text-base text-neutral-black hover:text-primary-tealBlack md:text-primary-teal"
>
<LuArrowLeft className="size-6 md:size-4" />
<span className="hidden md:flex">
{t('Back')} {backTo.label ? `${t('to')} ${backTo.label}` : ''}
</span>
</Link>
</div>

<div className="flex justify-center text-center">
<Header1 className="font-serif text-title-sm text-neutral-charcoal sm:text-title-sm">
{title}
</Header1>
</div>

<div className="flex items-center justify-end gap-2">
<LocaleChooser />
<UserAvatarMenu />
</div>
</header>
);
};
106 changes: 106 additions & 0 deletions apps/app/src/components/decisions/ProfileUsersAccessPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use 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';
import { useEffect, useState } from 'react';
import type { SortDescriptor } from 'react-aria-components';

import { useTranslations } from '@/lib/i18n';

import { ProfileUsersAccessTable } from './ProfileUsersAccessTable';

// Sort columns supported by profile.listUsers endpoint
type SortColumn = 'name' | 'email' | 'role';

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<SortDescriptor>({
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, reset]);

// Convert React Aria sort descriptor to API format
const orderBy = sortDescriptor.column as SortColumn;
const dir: SortDir =
sortDescriptor.direction === 'ascending' ? 'asc' : 'desc';

// Build query input - only include query if >= 2 chars
const queryInput = {
profileId,
cursor,
limit: ITEMS_PER_PAGE,
orderBy,
dir,
query: debouncedQuery.length >= 2 ? debouncedQuery : undefined,
};

// 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);

// Fetch roles in parallel to avoid waterfall loading
const { data: rolesData, isPending: rolesPending } =
trpc.organization.getRoles.useQuery();

const { items: profileUsers = [], next } = data ?? {};
const { roles = [] } = rolesData ?? {};

const onNext = () => {
if (next) {
handleNext(next);
}
};

return (
<div className="flex flex-col gap-4">
<h2 className="font-serif text-title-sm font-light text-neutral-black">
Copy link
Collaborator Author

@scazan scazan Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to look at this and maybe adjust to a consistent sizing. Will ping design about this separately

{t('Members')}
</h2>

<SearchField
placeholder={t('Search')}
value={searchQuery}
onChange={setSearchQuery}
className="w-full max-w-96"
/>

<ProfileUsersAccessTable
profileUsers={profileUsers}
profileId={profileId}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
isLoading={isPending || rolesPending}
isError={isError}
onRetry={() => void refetch()}
roles={roles}
/>

<Pagination
next={next ? onNext : undefined}
previous={canGoPrevious ? handlePrevious : undefined}
/>
</div>
);
};
Loading