-
Notifications
You must be signed in to change notification settings - Fork 191
Feat: enhance permission display and handling in permissions table #2466
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
base: main
Are you sure you want to change the base?
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,43 +1,119 @@ | ||||||||||||||
<script lang="ts"> | ||||||||||||||
import { type ComponentProps } from 'svelte'; | ||||||||||||||
import { sdk } from '$lib/stores/sdk'; | ||||||||||||||
import type { Models } from '@appwrite.io/console'; | ||||||||||||||
import { AvatarInitials } from '../'; | ||||||||||||||
import { isSmallViewport } from '$lib/stores/viewport'; | ||||||||||||||
import { | ||||||||||||||
Button, | ||||||||||||||
Badge, | ||||||||||||||
Divider, | ||||||||||||||
Icon, | ||||||||||||||
InteractiveText, | ||||||||||||||
Layout, | ||||||||||||||
Link, | ||||||||||||||
Popover, | ||||||||||||||
Spinner, | ||||||||||||||
Typography | ||||||||||||||
} from '@appwrite.io/pink-svelte'; | ||||||||||||||
import Avatar from '../avatar.svelte'; | ||||||||||||||
import { IconAnonymous, IconExternalLink, IconMinusSm } from '@appwrite.io/pink-icons-svelte'; | ||||||||||||||
import { base } from '$app/paths'; | ||||||||||||||
import { IconAnonymous, IconMinusSm } from '@appwrite.io/pink-icons-svelte'; | ||||||||||||||
import { page } from '$app/state'; | ||||||||||||||
import { menuOpen } from '$lib/components/menu/store'; | ||||||||||||||
import { base } from '$app/paths'; | ||||||||||||||
import { formatName } from '$lib/helpers/string'; | ||||||||||||||
export let role: string; | ||||||||||||||
export let placement: ComponentProps<Popover>['placement'] = 'bottom-start'; | ||||||||||||||
interface ParsedPermission { | ||||||||||||||
HarshMN2345 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
type: 'user' | 'team' | 'other'; | ||||||||||||||
id: string; | ||||||||||||||
roleName?: string; | ||||||||||||||
isValid: boolean; | ||||||||||||||
} | ||||||||||||||
async function getData( | ||||||||||||||
permission: string | ||||||||||||||
): Promise< | ||||||||||||||
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>> | ||||||||||||||
function parsePermission(permission: string): ParsedPermission { | ||||||||||||||
try { | ||||||||||||||
const [type, rest] = permission.split(':'); | ||||||||||||||
if (!rest) { | ||||||||||||||
return { type: 'other', id: permission, isValid: false }; | ||||||||||||||
} | ||||||||||||||
const [id, roleName] = rest.split('/'); | ||||||||||||||
if (!id) { | ||||||||||||||
return { type: 'other', id: permission, isValid: false }; | ||||||||||||||
} | ||||||||||||||
if (type === 'user' || type === 'team') { | ||||||||||||||
return { | ||||||||||||||
type: type as 'user' | 'team', | ||||||||||||||
id, | ||||||||||||||
roleName, | ||||||||||||||
isValid: true | ||||||||||||||
}; | ||||||||||||||
} | ||||||||||||||
return { type: 'other', id: permission, isValid: false }; | ||||||||||||||
} catch (error) { | ||||||||||||||
return { type: 'other', id: permission, isValid: false }; | ||||||||||||||
} | ||||||||||||||
} | ||||||||||||||
async function getData(permission: string): Promise< | ||||||||||||||
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>> & { | ||||||||||||||
notFound?: boolean; | ||||||||||||||
roleName?: string; | ||||||||||||||
customName?: string; | ||||||||||||||
} | ||||||||||||||
> { | ||||||||||||||
const role = permission.split(':')[0]; | ||||||||||||||
const id = permission.split(':')[1].split('/')[0]; | ||||||||||||||
if (role === 'user') { | ||||||||||||||
const user = await sdk | ||||||||||||||
.forProject(page.params.region, page.params.project) | ||||||||||||||
.users.get({ userId: id }); | ||||||||||||||
return user; | ||||||||||||||
const parsed = parsePermission(permission); | ||||||||||||||
if (!parsed.isValid || parsed.type === 'other') { | ||||||||||||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id }; | ||||||||||||||
} | ||||||||||||||
if (role === 'team') { | ||||||||||||||
const team = await sdk | ||||||||||||||
.forProject(page.params.region, page.params.project) | ||||||||||||||
.teams.get({ teamId: id }); | ||||||||||||||
return team; | ||||||||||||||
if (parsed.type === 'user') { | ||||||||||||||
try { | ||||||||||||||
const user = await sdk | ||||||||||||||
.forProject(page.params.region, page.params.project) | ||||||||||||||
.users.get({ userId: parsed.id }); | ||||||||||||||
return user; | ||||||||||||||
} catch (error) { | ||||||||||||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id }; | ||||||||||||||
} | ||||||||||||||
} | ||||||||||||||
if (parsed.type === 'team') { | ||||||||||||||
try { | ||||||||||||||
const team = await sdk | ||||||||||||||
.forProject(page.params.region, page.params.project) | ||||||||||||||
.teams.get({ teamId: parsed.id }); | ||||||||||||||
return team; | ||||||||||||||
} catch (error) { | ||||||||||||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id }; | ||||||||||||||
} | ||||||||||||||
} | ||||||||||||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id }; | ||||||||||||||
} | ||||||||||||||
let isMouseOverTooltip = false; | ||||||||||||||
function hidePopover(hideTooltip: () => void, timeout = true) { | ||||||||||||||
if (!timeout) { | ||||||||||||||
isMouseOverTooltip = false; | ||||||||||||||
return hideTooltip(); | ||||||||||||||
} | ||||||||||||||
setTimeout(() => { | ||||||||||||||
if (!isMouseOverTooltip) { | ||||||||||||||
hideTooltip(); | ||||||||||||||
} | ||||||||||||||
}, 150); | ||||||||||||||
} | ||||||||||||||
function isCustomPermission(role: string): boolean { | ||||||||||||||
const parsed = parsePermission(role); | ||||||||||||||
return !!parsed.roleName || !parsed.isValid; | ||||||||||||||
} | ||||||||||||||
</script> | ||||||||||||||
|
||||||||||||||
|
@@ -48,70 +124,178 @@ | |||||||||||||
{:else if role === 'any'} | ||||||||||||||
<div>Any</div> | ||||||||||||||
{:else} | ||||||||||||||
<Popover let:toggle placement="bottom-start"> | ||||||||||||||
<Link.Button on:click={toggle}>{role}</Link.Button> | ||||||||||||||
<div let:showing slot="tooltip" style:width="200px"> | ||||||||||||||
{#key showing} | ||||||||||||||
{#await getData(role)} | ||||||||||||||
<Layout.Stack alignItems="center"> | ||||||||||||||
<Spinner /> | ||||||||||||||
<Popover let:show let:hide {placement} portal> | ||||||||||||||
<button | ||||||||||||||
on:mouseenter={() => { | ||||||||||||||
if (!$menuOpen) { | ||||||||||||||
setTimeout(show, 150); | ||||||||||||||
} | ||||||||||||||
}} | ||||||||||||||
on:mouseleave={() => hidePopover(hide)}> | ||||||||||||||
<slot> | ||||||||||||||
HarshMN2345 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
{#if isCustomPermission(role)} | ||||||||||||||
<Link.Anchor> | ||||||||||||||
{formatName(role, $isSmallViewport ? 8 : 15)} | ||||||||||||||
</Link.Anchor> | ||||||||||||||
HarshMN2345 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
{:else} | ||||||||||||||
<Layout.Stack direction="row" gap="s" alignItems="center" inline> | ||||||||||||||
<Typography.Text> | ||||||||||||||
{#await getData(role)} | ||||||||||||||
HarshMN2345 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
{role} | ||||||||||||||
{:then data} | ||||||||||||||
{formatName( | ||||||||||||||
data.name ?? data?.email ?? data?.phone ?? '-', | ||||||||||||||
$isSmallViewport ? 5 : 12 | ||||||||||||||
)} | ||||||||||||||
{/await} | ||||||||||||||
</Typography.Text> | ||||||||||||||
<Badge | ||||||||||||||
size="xs" | ||||||||||||||
variant="secondary" | ||||||||||||||
content={role.startsWith('user') ? 'User' : 'Team'} /> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{:then data} | ||||||||||||||
{@const isUser = role.startsWith('user')} | ||||||||||||||
{@const isTeam = role.startsWith('team')} | ||||||||||||||
{@const isAnonymous = !data.email && !data.phone && !data.name && isUser} | ||||||||||||||
<Layout.Stack> | ||||||||||||||
<Layout.Stack direction="row" gap="s" alignItems="center"> | ||||||||||||||
{#if isAnonymous} | ||||||||||||||
<Avatar alt="avatar" size="xs"> | ||||||||||||||
<Icon icon={IconAnonymous} size="s" /> | ||||||||||||||
</Avatar> | ||||||||||||||
{:else if data.name} | ||||||||||||||
<AvatarInitials name={data.name} size="xs" /> | ||||||||||||||
{:else} | ||||||||||||||
<Avatar alt="avatar" size="xs"> | ||||||||||||||
<Icon icon={IconMinusSm} size="s" /> | ||||||||||||||
</Avatar> | ||||||||||||||
{/if} | ||||||||||||||
<Typography.Text truncate color="--fgcolor-neutral-primary"> | ||||||||||||||
{data.name ?? data?.email ?? data?.phone ?? '-'} | ||||||||||||||
</Typography.Text> | ||||||||||||||
{/if} | ||||||||||||||
</slot> | ||||||||||||||
</button> | ||||||||||||||
Comment on lines
151
to
182
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accessibility: fix nested interactive, add keyboard support, and prevent implicit form submit
- <Popover let:show let:hide {placement} portal>
- <button
- on:mouseenter={() => {
+ <Popover let:show let:hide {placement} portal>
+ <button
+ type="button"
+ aria-haspopup="dialog"
+ aria-label="View permission details"
+ on:mouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
}
}}
- on:mouseleave={() => hidePopover(hide)}>
+ on:mouseleave={() => hidePopover(hide)}
+ on:focus={() => { if (!$menuOpen) show(); }}
+ on:blur={() => hidePopover(hide)}
+ on:keydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ show();
+ }
+ if (e.key === 'Escape') {
+ hide();
+ }
+ }}>
<slot>
{#if isCustomPermission(role)}
- <Link.Anchor>
- {formatName(role, $isSmallViewport ? 8 : 15)}
- </Link.Anchor>
+ <Typography.Text>
+ {formatName(role, $isSmallViewport ? 8 : 15)}
+ </Typography.Text>
{:else}
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
<Typography.Text>
- {#await getData(role)}
+ {#await dataPromise}
{role}
{:then data}
{formatName(
data.name ?? data?.email ?? data?.phone ?? '-',
$isSmallViewport ? 5 : 12
)}
{/await}
</Typography.Text>
<Badge
size="xs"
variant="secondary"
- content={role.startsWith('user') ? 'User' : 'Team'} />
+ content={parsePermission(role).type === 'user' ? 'User' : 'Team'} />
</Layout.Stack>
{/if}
</slot>
</button> To support let dataPromise: ReturnType<typeof getData>;
$: dataPromise = getData(role); near the other script declarations. |
||||||||||||||
|
||||||||||||||
<div | ||||||||||||||
let:hide | ||||||||||||||
let:showing | ||||||||||||||
slot="tooltip" | ||||||||||||||
role="tooltip" | ||||||||||||||
class="popover" | ||||||||||||||
on:mouseenter={() => (isMouseOverTooltip = true)} | ||||||||||||||
on:mouseleave={() => hidePopover(hide, false)}> | ||||||||||||||
{#if showing} | ||||||||||||||
<Layout.Stack gap="s" alignContent="flex-start"> | ||||||||||||||
{#await getData(role)} | ||||||||||||||
HarshMN2345 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
<Layout.Stack alignItems="center"> | ||||||||||||||
<Spinner /> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{:then data} | ||||||||||||||
{#if data.notFound} | ||||||||||||||
<Layout.Stack gap="s" alignItems="flex-start"> | ||||||||||||||
<Layout.Stack | ||||||||||||||
direction="row" | ||||||||||||||
gap="s" | ||||||||||||||
alignItems="center" | ||||||||||||||
justifyContent="flex-start"> | ||||||||||||||
<Avatar alt="avatar" size="m"> | ||||||||||||||
<Icon icon={IconMinusSm} size="s" /> | ||||||||||||||
</Avatar> | ||||||||||||||
|
||||||||||||||
<Layout.Stack alignItems="flex-start" gap="xxs"> | ||||||||||||||
<Layout.Stack style="padding-left: 0.25rem;"> | ||||||||||||||
<Typography.Text | ||||||||||||||
size="s" | ||||||||||||||
color="--fgcolor-neutral-primary"> | ||||||||||||||
{data.customName} | ||||||||||||||
</Typography.Text> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{#if data.roleName} | ||||||||||||||
<InteractiveText | ||||||||||||||
isVisible | ||||||||||||||
variant="copy" | ||||||||||||||
text={data.roleName} | ||||||||||||||
value={data.roleName} /> | ||||||||||||||
{:else} | ||||||||||||||
<InteractiveText | ||||||||||||||
isVisible | ||||||||||||||
variant="copy" | ||||||||||||||
text={role} | ||||||||||||||
value={role} /> | ||||||||||||||
{/if} | ||||||||||||||
</Layout.Stack> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{:else} | ||||||||||||||
{@const isUser = role.startsWith('user')} | ||||||||||||||
{@const isAnonymous = | ||||||||||||||
!data.email && !data.phone && !data.name && isUser} | ||||||||||||||
{@const id = role.split(':')[1].split('/')[0]} | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Use Line 238 manually parses the permission string with Apply this diff: + {@const parsed = parsePermission(role)}
{@const isUser = role.startsWith('user')}
{@const isAnonymous =
!data.email && !data.phone && !data.name && isUser}
- {@const id = role.split(':')[1].split('/')[0]}
+ {@const id = parsed.id} 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||
|
||||||||||||||
<Layout.Stack gap="s" alignItems="flex-start"> | ||||||||||||||
<Layout.Stack | ||||||||||||||
direction="row" | ||||||||||||||
gap="s" | ||||||||||||||
alignItems="center" | ||||||||||||||
justifyContent="flex-start"> | ||||||||||||||
{#if isAnonymous} | ||||||||||||||
<Avatar alt="avatar" size="m"> | ||||||||||||||
<Icon icon={IconAnonymous} size="s" /> | ||||||||||||||
</Avatar> | ||||||||||||||
{:else if data.name} | ||||||||||||||
<AvatarInitials name={data.name} size="m" /> | ||||||||||||||
{:else} | ||||||||||||||
<Avatar alt="avatar" size="m"> | ||||||||||||||
<Icon icon={IconMinusSm} size="s" /> | ||||||||||||||
</Avatar> | ||||||||||||||
{/if} | ||||||||||||||
|
||||||||||||||
<Layout.Stack alignItems="flex-start" gap="xxs"> | ||||||||||||||
<Layout.Stack style="padding-left: 0.25rem;"> | ||||||||||||||
<Link.Anchor | ||||||||||||||
variant="quiet" | ||||||||||||||
href={role.startsWith('user') | ||||||||||||||
? `${base}/project-${page.params.region}-${page.params.project}/auth/user-${id}` | ||||||||||||||
: `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${id}`}> | ||||||||||||||
<Typography.Text | ||||||||||||||
size="s" | ||||||||||||||
color="--fgcolor-neutral-primary"> | ||||||||||||||
{formatName( | ||||||||||||||
data.name ?? | ||||||||||||||
data?.email ?? | ||||||||||||||
data?.phone ?? | ||||||||||||||
'-', | ||||||||||||||
$isSmallViewport ? 12 : 24 | ||||||||||||||
)} | ||||||||||||||
</Typography.Text> | ||||||||||||||
</Link.Anchor> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
<InteractiveText | ||||||||||||||
isVisible | ||||||||||||||
variant="copy" | ||||||||||||||
text={id} | ||||||||||||||
value={id} /> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
</Layout.Stack> | ||||||||||||||
|
||||||||||||||
<Divider /> | ||||||||||||||
{#if isUser} | ||||||||||||||
{#if data?.email} | ||||||||||||||
<Typography.Text truncate>Email: {data?.email}</Typography.Text> | ||||||||||||||
{/if} | ||||||||||||||
{#if data?.phone} | ||||||||||||||
<Typography.Text truncate>Phone: {data?.phone}</Typography.Text> | ||||||||||||||
{/if} | ||||||||||||||
<div> | ||||||||||||||
<Button.Anchor | ||||||||||||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${data?.$id}`} | ||||||||||||||
size="xs" | ||||||||||||||
target="_blank" | ||||||||||||||
variant="secondary"> | ||||||||||||||
View user | ||||||||||||||
<Icon slot="end" icon={IconExternalLink} size="s" /> | ||||||||||||||
</Button.Anchor> | ||||||||||||||
</div> | ||||||||||||||
{:else if isTeam} | ||||||||||||||
<Typography.Text>Members: {data?.total}</Typography.Text> | ||||||||||||||
<div> | ||||||||||||||
<Button.Anchor | ||||||||||||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${data?.$id}`} | ||||||||||||||
size="s" | ||||||||||||||
target="_blank" | ||||||||||||||
variant="secondary"> | ||||||||||||||
View team | ||||||||||||||
<Icon slot="end" icon={IconExternalLink} size="s" /> | ||||||||||||||
</Button.Anchor> | ||||||||||||||
</div> | ||||||||||||||
{#if isUser && (data.email || data.phone)} | ||||||||||||||
<Divider /> | ||||||||||||||
<Layout.Stack gap="xs" alignItems="flex-start"> | ||||||||||||||
{#if data.email} | ||||||||||||||
<Typography.Text | ||||||||||||||
size="xs" | ||||||||||||||
color="--fgcolor-neutral-secondary"> | ||||||||||||||
Email: {data.email} | ||||||||||||||
</Typography.Text> | ||||||||||||||
{/if} | ||||||||||||||
{#if data.phone} | ||||||||||||||
<Typography.Text | ||||||||||||||
size="xs" | ||||||||||||||
color="--fgcolor-neutral-secondary"> | ||||||||||||||
Phone: {data.phone} | ||||||||||||||
</Typography.Text> | ||||||||||||||
{/if} | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{/if} | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{/if} | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{/await} | ||||||||||||||
{/key} | ||||||||||||||
{/await} | ||||||||||||||
</Layout.Stack> | ||||||||||||||
{/if} | ||||||||||||||
</div> | ||||||||||||||
</Popover> | ||||||||||||||
{/if} | ||||||||||||||
|
||||||||||||||
<style lang="scss"> | ||||||||||||||
.popover { | ||||||||||||||
display: flex; | ||||||||||||||
width: 260px; | ||||||||||||||
min-width: 260px; | ||||||||||||||
padding: var(--space-5, 10px) var(--space-6, 12px); | ||||||||||||||
align-items: flex-start; | ||||||||||||||
gap: var(--gap-XXS, 4px); | ||||||||||||||
margin: -1rem; | ||||||||||||||
} | ||||||||||||||
</style> |
Uh oh!
There was an error while loading. Please reload this page.