-
Notifications
You must be signed in to change notification settings - Fork 2
Profile Process Permissions UI #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
scazan
wants to merge
28
commits into
dev
Choose a base branch
from
decision-permission-table-ui
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+577
−18
Open
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 a94dc0b
Add ProfileAvatar, update spacings on Roles page
scazan f62e76c
Update datatable for members to avoid SSR issues
scazan a0f3bab
Allow for retries from APIErrorBoundary
scazan f4b7bd5
Allow retry on error, style error
scazan b2f8b46
Update size of dropdown
scazan f71cbe4
Enable local sortin
scazan d367c12
Switch to useQuery
scazan bc6f06d
Correct variant on button
scazan 1ae67d2
Fix radius-md
scazan 026301e
Remove invite button for now
scazan bb33698
Remove temp invite decision modal
scazan c3e5a65
Change name to be more generic
scazan 1efa57e
Update query
scazan 353fe87
Small fixes to email and standard tailwind sizing, add translations
scazan cf73306
Update naming on ProfileMembersContent
scazan 80f0de7
Don't translate a string passed in
scazan e7e9807
Use encoder types
scazan 065d4b1
format
scazan dbbe48c
Convert other EmptyStates to new component
scazan d8cfaff
format
scazan ae0f808
Optimize a bit
scazan 7a46512
Use standard tailwind calc
scazan bd08cd7
Use render props for APIErrorBoundary
scazan 908c87e
Remove hasMore
scazan 608a9b8
Export ProfileUser type, formatting
scazan 22b1bac
Use ClientOnly for table
scazan beae2d1
Small fixes
scazan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} />; | ||
| } |
20 changes: 20 additions & 0 deletions
20
apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
53 changes: 53 additions & 0 deletions
53
apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
50
apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
106
apps/app/src/components/decisions/ProfileUsersAccessPage.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"> | ||
| {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> | ||
| ); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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