revise front end to use shared components

This commit is contained in:
Nate Kelley 2025-07-17 21:58:09 -06:00
parent cd8a50403f
commit 0a34db8989
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
11 changed files with 173 additions and 196 deletions

View File

@ -2,6 +2,10 @@
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"dev": {}
"dev": {
"dependsOn": ["@buster/database#start"],
"with": [],
"outputs": []
}
}
}

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import type { ShareAssetType, ShareRole } from '@buster/server-shared/share';
import type { ShareAssetType, ShareRole, WorkspaceShareRole } from '@buster/server-shared/share';
import type { DropdownItem } from '@/components/ui/dropdown';
import { Dropdown } from '@/components/ui/dropdown';
import { ChevronDown } from '@/components/ui/icons/NucleoIconFilled';
@ -7,24 +7,45 @@ import { Paragraph, Text } from '@/components/ui/typography';
import { useMemoizedFn } from '@/hooks';
import { cn } from '@/lib/classMerge';
type DropdownValue = ShareRole | 'remove' | 'notShared';
type DropdownValue = ShareRole | WorkspaceShareRole | 'remove';
export const AccessDropdown: React.FC<{
className?: string;
showRemove?: boolean;
shareLevel?: ShareRole | null;
onChangeShareLevel?: (level: ShareRole | null) => void;
type AccessDropdownBaseProps = {
assetType: ShareAssetType;
disabled: boolean;
}> = ({
className?: string;
};
type AccessDropdownUserProps = {
showRemove?: boolean;
shareLevel?: ShareRole | null;
type: 'user';
onChangeShareLevel?: (level: ShareRole | null) => void;
};
type AccessDropdownWorkspaceProps = {
type: 'workspace';
shareLevel?: WorkspaceShareRole | null;
onChangeShareLevel?: (level: WorkspaceShareRole | null) => void;
};
type AccessDropdownProps = AccessDropdownBaseProps &
(AccessDropdownUserProps | AccessDropdownWorkspaceProps);
export const AccessDropdown: React.FC<AccessDropdownProps> = ({
shareLevel,
showRemove = true,
disabled,
className = '',
onChangeShareLevel,
assetType
assetType,
...props
}) => {
const showRemove = props.type === 'user' && props.showRemove !== false;
const items = useMemo(() => {
if (props.type === 'workspace') {
return workspaceItems;
}
const baseItems: DropdownItem<DropdownValue>[] = [...(itemsRecord[assetType] || [])];
if (showRemove) {
@ -38,7 +59,7 @@ export const AccessDropdown: React.FC<{
...item,
selected: item.value === shareLevel
}));
}, [showRemove, shareLevel, assetType]);
}, [showRemove, shareLevel, assetType, props.type]);
const selectedLabel = useMemo(() => {
const selectedItem = items.find((item) => item.selected) || OWNER_ITEM;
@ -51,16 +72,17 @@ export const AccessDropdown: React.FC<{
return 'Full access';
case 'canEdit':
return 'Can edit';
case 'canFilter':
return 'Can filter';
case 'canView':
return 'Can view';
case 'owner':
return 'Owner';
case 'remove':
return 'Remove';
case 'notShared':
case 'none':
return 'Not shared';
default:
const _exhaustiveCheck: never = value;
return value;
}
}, [items]);
@ -128,11 +150,6 @@ const dashboardItems: DropdownItem<ShareRole>[] = [
label: 'Can edit',
secondaryLabel: 'Can edit but not share with others.'
},
// {
// value: 'canFilter',
// label: 'Can filter',
// secondaryLabel: 'Can filter dashboards but not edit.'
// },
{
value: 'canView',
label: 'Can view',
@ -158,6 +175,29 @@ const collectionItems: DropdownItem<ShareRole>[] = [
}
];
const workspaceItems: DropdownItem<WorkspaceShareRole>[] = [
{
value: 'fullAccess',
label: 'Full access',
secondaryLabel: 'Can edit and share with others.'
},
{
value: 'canEdit',
label: 'Can edit',
secondaryLabel: 'Can edit, but not share with others.'
},
{
value: 'canView',
label: 'Can view',
secondaryLabel: 'Cannot edit or share with others.'
},
{
value: 'none',
label: 'Not shared',
secondaryLabel: 'Does not have access.'
}
];
const itemsRecord: Record<ShareAssetType, DropdownItem<ShareRole>[]> = {
['dashboard']: dashboardItems,
['metric']: metricItems,

View File

@ -1,37 +0,0 @@
import React from 'react';
import { useMemoizedFn } from '@/hooks';
import { AccessDropdown } from './AccessDropdown';
import type { ShareAssetType, ShareRole } from '@buster/server-shared/share';
import { AvatarUserButton } from '../../ui/avatar/AvatarUserButton';
export const IndividualSharePerson: React.FC<{
name?: string;
email: string;
role: ShareRole;
avatar_url?: string | null;
onUpdateShareRole: (email: string, role: ShareRole | null) => void;
assetType: ShareAssetType;
disabled: boolean;
}> = React.memo(({ name, onUpdateShareRole, email, avatar_url, role, assetType, disabled }) => {
const onChangeShareLevel = useMemoizedFn((v: ShareRole | null) => {
onUpdateShareRole(email, v);
});
return (
<div
className="flex h-8 items-center justify-between space-x-2 overflow-hidden"
data-testid={`share-person-${email}`}>
<AvatarUserButton username={name} email={email} avatarUrl={avatar_url} avatarSize={24} />
<AccessDropdown
shareLevel={role}
showRemove={true}
disabled={disabled}
onChangeShareLevel={onChangeShareLevel}
assetType={assetType}
/>
</div>
);
});
IndividualSharePerson.displayName = 'IndividualSharePerson';

View File

@ -1,16 +1,23 @@
import React from 'react';
import type { ShareAssetType, ShareConfig, ShareRole, WorkspaceShareRole } from '@buster/server-shared/share';
import React, { useMemo } from 'react';
import type {
ShareAssetType,
ShareConfig,
ShareRole,
WorkspaceShareRole
} from '@buster/server-shared/share';
import { useUnshareCollection, useUpdateCollectionShare } from '@/api/buster_rest/collections';
import { useUnshareDashboard, useUpdateDashboardShare } from '@/api/buster_rest/dashboards';
import { useUnshareMetric, useUpdateMetricShare } from '@/api/buster_rest/metrics';
import { useMemoizedFn } from '@/hooks';
import { cn } from '@/lib/classMerge';
import { IndividualSharePerson } from './IndividualSharePerson';
import { ShareMenuContentEmbed } from './ShareMenuContentEmbed';
import { ShareMenuContentPublish } from './ShareMenuContentPublish';
import type { ShareMenuTopBarOptions } from './ShareMenuTopBar';
import { ShareMenuInvite } from './ShareMenuInvite';
import { WorkspaceShareSection } from './WorkspaceShareSection';
import { ShareRowItem } from './ShareRowItem';
import { WorkspaceAvatar } from './WorkspaceAvatar';
import pluralize from 'pluralize';
export const ShareMenuContentBody: React.FC<{
selectedOptions: ShareMenuTopBarOptions;
@ -55,7 +62,14 @@ export const ShareMenuContentBody: React.FC<{
ShareMenuContentBody.displayName = 'ShareMenuContentBody';
const ShareMenuContentShare: React.FC<ShareMenuContentBodyProps> = React.memo(
({ canEditPermissions, assetType, individual_permissions, assetId, className, shareAssetConfig }) => {
({
canEditPermissions,
assetType,
individual_permissions,
assetId,
className,
shareAssetConfig
}) => {
const { mutateAsync: onUpdateMetricShare } = useUpdateMetricShare();
const { mutateAsync: onUpdateDashboardShare } = useUpdateDashboardShare();
const { mutateAsync: onUpdateCollectionShare } = useUpdateCollectionShare();
@ -64,6 +78,7 @@ const ShareMenuContentShare: React.FC<ShareMenuContentBodyProps> = React.memo(
const { mutateAsync: onUnshareCollection } = useUnshareCollection();
const hasIndividualPermissions = !!individual_permissions?.length;
const workspaceMemberCount = shareAssetConfig.workspace_member_count || 0;
const onUpdateShareRole = useMemoizedFn(async (email: string, role: ShareRole | null) => {
if (role) {
@ -130,24 +145,36 @@ const ShareMenuContentShare: React.FC<ShareMenuContentBodyProps> = React.memo(
{hasIndividualPermissions && (
<div className="flex flex-col space-y-2.5 overflow-hidden">
{individual_permissions?.map((permission) => (
<IndividualSharePerson
key={permission.email}
{...permission}
onUpdateShareRole={onUpdateShareRole}
<ShareRowItem
primary={permission.name}
secondary={permission.email}
role={permission.role}
avatar={permission.avatar_url}
onChangeShareLevel={useMemoizedFn((role) =>
onUpdateShareRole(permission.email, role)
)}
assetType={assetType}
disabled={!canEditPermissions || permission.role === 'owner'}
type="user"
/>
))}
</div>
)}
{canEditPermissions && (
<WorkspaceShareSection
shareAssetConfig={shareAssetConfig}
<ShareRowItem
primary={'Workspace'}
secondary={`Share with ${workspaceMemberCount} ${pluralize('member', workspaceMemberCount)}`}
role={shareAssetConfig.workspace_sharing || 'none'}
type="workspace"
avatar={useMemo(
() => (
<WorkspaceAvatar />
),
[]
)}
onChangeShareLevel={onUpdateWorkspacePermissions}
assetType={assetType}
assetId={assetId}
canEditPermissions={canEditPermissions}
onUpdateWorkspacePermissions={onUpdateWorkspacePermissions}
/>
)}
</div>

View File

@ -143,6 +143,7 @@ export const ShareMenuInvite: React.FC<ShareMenuInviteProps> = React.memo(
onChangeShareLevel={onChangeAccessDropdown}
assetType={assetType}
disabled={false}
type="user"
/>
)}
</div>

View File

@ -0,0 +1,42 @@
import type { ShareAssetType, ShareRole, WorkspaceShareRole } from '@buster/server-shared/share';
import React from 'react';
import { AvatarUserButton } from '../../ui/avatar/AvatarUserButton';
import { AccessDropdown } from './AccessDropdown';
type ShareRowItemBaseProps = {
primary: string | undefined;
secondary: string | undefined;
avatar?: string | null | React.ReactNode;
disabled?: boolean;
assetType: ShareAssetType;
};
type ShareRowItemUserProps = {
type: 'user';
role: ShareRole;
showRemove?: boolean;
onChangeShareLevel?: (role: ShareRole | null) => void;
};
type ShareRowItemWorkspaceProps = {
type: 'workspace';
role: WorkspaceShareRole;
onChangeShareLevel?: (role: WorkspaceShareRole | null) => void;
};
type ShareRowItemProps = ShareRowItemBaseProps &
(ShareRowItemUserProps | ShareRowItemWorkspaceProps);
export const ShareRowItem: React.FC<ShareRowItemProps> = React.memo(
({ primary, secondary, avatar, disabled = false, assetType, ...props }) => {
return (
<div
className="flex h-8 items-center justify-between space-x-2 overflow-hidden"
data-testid={`share-row-${primary || secondary}`}>
<AvatarUserButton username={primary} email={secondary} avatarUrl={avatar} avatarSize={24} />
<AccessDropdown disabled={disabled} assetType={assetType} {...props} />
</div>
);
}
);

View File

@ -0,0 +1,17 @@
import React from 'react';
import { ApartmentBuilding } from '@/components/ui/icons';
import { cn } from '@/lib/classMerge';
export const WorkspaceAvatar: React.FC<{
className?: string;
}> = React.memo(({ className }) => {
return (
<div
className={cn(
'text-md flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600',
className
)}>
<ApartmentBuilding />
</div>
);
});

View File

@ -1,114 +0,0 @@
import React from 'react';
import type { ShareAssetType, ShareConfig, WorkspaceShareRole } from '@buster/server-shared/share';
import { Dropdown } from '@/components/ui/dropdown';
import type { DropdownItem } from '@/components/ui/dropdown';
import { ChevronDown } from '@/components/ui/icons/NucleoIconFilled';
import { ApartmentBuilding } from '@/components/ui/icons/NucleoIconFilled';
import { Text } from '@/components/ui/typography';
import { useMemoizedFn } from '@/hooks';
import { cn } from '@/lib/classMerge';
const workspaceShareRoleItems: DropdownItem<WorkspaceShareRole>[] = [
{
value: 'fullAccess',
label: 'Full access',
secondaryLabel: 'Can edit and share with others.'
},
{
value: 'canEdit',
label: 'Can edit',
secondaryLabel: 'Can edit, but not share with others.'
},
{
value: 'canView',
label: 'Can view',
secondaryLabel: 'Cannot edit or share with others.'
},
{
value: 'none',
label: 'Not shared',
secondaryLabel: 'Does not have access.'
}
];
interface WorkspaceShareSectionProps {
shareAssetConfig: ShareConfig;
assetType: ShareAssetType;
assetId: string;
canEditPermissions: boolean;
onUpdateWorkspacePermissions: (role: WorkspaceShareRole | null) => void;
}
export const WorkspaceShareSection: React.FC<WorkspaceShareSectionProps> = React.memo(({
shareAssetConfig,
canEditPermissions,
onUpdateWorkspacePermissions
}) => {
const currentRole = shareAssetConfig.workspace_sharing || 'none';
const selectedLabel = React.useMemo(() => {
const selectedItem = workspaceShareRoleItems.find(item => item.value === currentRole);
return selectedItem?.label || 'Not shared';
}, [currentRole]);
const onSelectMenuItem = useMemoizedFn((value: string) => {
onUpdateWorkspacePermissions(value as WorkspaceShareRole);
});
const items = React.useMemo(() => {
return workspaceShareRoleItems.map(item => ({
...item,
selected: item.value === currentRole
}));
}, [currentRole]);
return (
<div className="flex h-8 items-center justify-between space-x-2 overflow-hidden">
<div className="flex w-full items-center gap-x-2 rounded-md p-1">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 text-md">
<ApartmentBuilding />
</div>
<div className="flex w-full flex-col gap-y-0 overflow-hidden">
<Text className="truncate">Workspace</Text>
<Text variant="secondary" size="sm" className="truncate">
Share with {shareAssetConfig.workspace_member_count?.toLocaleString() || 0} members
</Text>
</div>
</div>
<Dropdown
items={items}
disabled={!canEditPermissions}
footerContent={
<div className="bg-item-hover flex justify-center overflow-hidden rounded-b p-2 px-2.5">
<Text variant="secondary" size="xs">
Sharing cannot override permissions set by your account admins.
</Text>
</div>
}
footerClassName="p-0!"
onSelect={onSelectMenuItem}
sideOffset={16}
selectType="single"
align="end"
side="bottom">
<Text
variant="secondary"
size="xs"
className={cn(
'flex! items-center! space-x-1',
canEditPermissions && 'cursor-pointer'
)}>
<span className="truncate">{selectedLabel}</span>
{canEditPermissions && (
<span className="text-2xs text-icon-color">
<ChevronDown />
</span>
)}
</Text>
</Dropdown>
</div>
);
});
WorkspaceShareSection.displayName = 'WorkspaceShareSection';

View File

@ -7,7 +7,7 @@ export const AvatarUserButton = React.forwardRef<
HTMLDivElement,
{
username?: string | null;
avatarUrl?: string | null;
avatarUrl?: string | null | React.ReactNode;
email?: string | null;
className?: string;
avatarSize?: number;
@ -17,7 +17,11 @@ export const AvatarUserButton = React.forwardRef<
return (
<div ref={ref} className={cn('flex w-full items-center gap-x-2 rounded-md p-1', className)}>
<Avatar size={avatarSize} fallbackClassName="text-base" image={avatarUrl} name={username} />
{typeof avatarUrl === 'string' || avatarUrl === null ? (
<Avatar size={avatarSize} fallbackClassName="text-base" image={avatarUrl} name={username} />
) : (
avatarUrl
)}
<div className="flex w-full flex-col gap-y-0 overflow-hidden">
<Text truncate className="flex-grow">
{username}

View File

@ -88,7 +88,7 @@ const useSupabaseContextInternal = ({
async ({ accessToken, expiresAt: _expiresAt }: { accessToken: string; expiresAt: number }) => {
setAccessToken(accessToken);
flushSync(() => {
openInfoMessage('Token refreshed');
//noop
});
}
);

View File

@ -4,17 +4,10 @@ export const ShareRoleSchema = z.enum([
'owner', //owner of the asset
'fullAccess', //same as owner, can share with others
'canEdit', //can edit, cannot share
'canFilter', //can filter dashboard
'canView', //can view asset
]);
export const WorkspaceShareRoleSchema = z.enum([
'owner',
'fullAccess',
'canEdit',
'canView',
'none',
]);
export const WorkspaceShareRoleSchema = z.enum([...ShareRoleSchema.options, 'none']);
export const ShareAssetTypeSchema = z.enum(['metric', 'dashboard', 'collection', 'chat']);