This commit is contained in:
Mike Cao committed 2026-08-04 07:58:44 -07:00
commit fc7d0d4e6c
5 files changed
+313 -142

No files matched your search

@@ -1,58 +1,18 @@
import { DataColumn, DataTable, Icon, type DataTableProps, Row, Text } from '@umami/react-zen';
import { Row, type DataTableProps } from '@umami/react-zen';
import { ShareDeleteButton } from '@/app/(main)/websites/[websiteId]/settings/ShareDeleteButton';
import { CopyButton } from '@/components/common/CopyButton';
import Link from '@/components/common/Link';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { ExternalLink } from '@/components/icons';
import { SimpleShareEditButton } from '@/components/share/SimpleShareEditButton';
import { SharedSharesTable } from '@/components/share/SharedSharesTable';
export function BoardSharesTable(props: DataTableProps) {
const { t, labels } = useMessages();
const { cloudMode } = useConfig();
const { isMobile } = useMobile();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
<DataColumn id="name" label={t(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={t(labels.shareUrl)} width="2fr" style={{ minWidth: 0 }}>
{({ slug }: any) => {
const url = getUrl(slug);
return (
<Row alignItems="center" gap="1" minWidth="0" width="100%" overflow="hidden">
<Row alignItems="center" gap minWidth="0" style={{ flex: 1 }}>
<Text title={url} style={{ minWidth: 0, flex: 1, overflowWrap: 'anywhere' }}>
<Link href={url} target="_blank" prefetch={false}>
{isMobile ? slug : url}
</Link>
</Text>
<Icon size="sm" strokeColor="muted" style={{ flexShrink: 0 }}>
<ExternalLink />
</Icon>
</Row>
<CopyButton value={url} label="Copy URL" />
</Row>
);
}}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => (
<SharedSharesTable
data={props.data as any[]}
renderActions={({ id, slug }) => (
<Row>
<SimpleShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
)}
</DataColumn>
</DataTable>
/>
);
}
@@ -0,0 +1,54 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@/test/render';
import { SharesTable } from './SharesTable';
vi.mock('@/components/hooks', () => ({
useConfig: () => ({ cloudMode: false }),
useMessages: () => ({
t: (value: string) => value,
labels: {
name: 'Name',
shareUrl: 'Share URL',
edit: 'Edit',
delete: 'Delete',
share: 'Share',
confirm: 'Confirm',
},
messages: { confirmRemove: 'Remove {target}' },
getErrorMessage: () => null,
}),
useMobile: () => ({ isMobile: false }),
useDeleteQuery: () => ({
mutateAsync: vi.fn(),
isPending: false,
error: null,
}),
useModified: () => ({
touch: vi.fn(),
}),
}));
vi.mock('@/components/common/ConfirmationForm', () => ({
ConfirmationForm: () => <div>confirmation</div>,
}));
vi.mock('./ShareEditForm', () => ({
ShareEditForm: () => <div>share edit form</div>,
}));
describe('SharesTable', () => {
test('renders the share url content in the table row with actions', () => {
render(<SharesTable data={[{ id: '1', name: 'hello', slug: 'world' }]} />, {
route: 'https://app.umami.is/settings',
});
const link = screen.getByRole('link', { name: 'http://localhost:3000/share/world' });
expect(link).toHaveAttribute('href', 'http://localhost:3000/share/world');
expect(screen.getByText('hello')).toBeInTheDocument();
expect(screen.getByText('Share URL')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /copy url/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
});
@@ -1,59 +1,133 @@
import { CopyButton } from '@/components/common/CopyButton';
import Link from '@/components/common/Link';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { ExternalLink } from '@/components/icons';
import { DataColumn, DataTable, type DataTableProps, Icon, Row, Text } from '@umami/react-zen';
import { ShareDeleteButton } from './ShareDeleteButton';
import { ShareEditButton } from './ShareEditButton';
import {
Button,
Dialog,
Icon,
Modal,
Row,
} from '@umami/react-zen';
import { useState } from 'react';
import { ControlledDialog } from '@/components/common/ControlledDialog';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useMobile, useModified } from '@/components/hooks';
import { Edit, Trash } from 'lucide-react';
import { SharedSharesTable } from '@/components/share/SharedSharesTable';
import { ShareEditForm } from './ShareEditForm';
export function SharesTable(props: DataTableProps) {
export function SharesTable({ data = [] }: { data?: any[] }) {
const { t, labels } = useMessages();
const { cloudMode } = useConfig();
const { isMobile } = useMobile();
const [editShareId, setEditShareId] = useState<string | null>(null);
const [deleteShare, setDeleteShare] = useState<{ id: string; slug: string } | null>(null);
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
return (
<>
<SharedSharesTable
data={data}
renderActions={({ id, slug }) => (
<Row>
<Button variant="quiet" aria-label={t(labels.edit)} onPress={() => setEditShareId(id)}>
<Icon>
<Edit />
</Icon>
</Button>
<Button
variant="quiet"
aria-label={t(labels.delete)}
onPress={() => setDeleteShare({ id, slug })}
>
<Icon>
<Trash />
</Icon>
</Button>
</Row>
)}
/>
<ControlledDialog>
<Modal isOpen={!!editShareId} onOpenChange={isOpen => !isOpen && setEditShareId(null)}>
<Dialog
title={t(labels.share)}
style={{
width: isMobile ? '100%' : '600px',
height: isMobile ? '100%' : undefined,
maxHeight: isMobile ? '100%' : 'min(80dvh, calc(100dvh - 40px))',
overflowY: 'auto',
padding: '32px',
}}
>
{editShareId && (
<ShareEditForm shareId={editShareId} onClose={() => setEditShareId(null)} />
)}
</Dialog>
</Modal>
</ControlledDialog>
{deleteShare && (
<ShareDeleteDialog
share={deleteShare}
onClose={() => setDeleteShare(null)}
title={t(labels.confirm)}
deleteLabel={t(labels.delete)}
/>
)}
</>
);
}
function ShareDeleteDialog({
share,
onClose,
title,
deleteLabel,
}: {
share: { id: string; slug: string } | null;
onClose: () => void;
title: string;
deleteLabel: string;
}) {
const { t, messages, getErrorMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${share.id}`);
const { touch } = useModified();
const handleConfirm = async () => {
if (!share) {
return;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
await mutateAsync(null, {
onSuccess: () => {
touch('shares');
onClose();
},
});
};
return (
<DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
<DataColumn id="name" label={t(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={t(labels.shareUrl)} width="2fr" style={{ minWidth: 0 }}>
{({ slug }: any) => {
const url = getUrl(slug);
return (
<Row alignItems="center" gap="1" minWidth="0" width="100%" overflow="hidden">
<Row alignItems="center" gap minWidth="0" style={{ flex: 1 }}>
<Text title={url} style={{ minWidth: 0, flex: 1, overflowWrap: 'anywhere' }}>
<Link href={url} target="_blank" prefetch={false}>
{isMobile ? slug : url}
</Link>
</Text>
<Icon size="sm" strokeColor="muted" style={{ flexShrink: 0 }}>
<ExternalLink />
</Icon>
</Row>
<CopyButton value={url} label="Copy URL" />
</Row>
);
}}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => {
return (
<Row>
<ShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
);
}}
</DataColumn>
</DataTable>
<ControlledDialog>
<Modal isOpen={!!share} onOpenChange={isOpen => !isOpen && onClose()}>
<Dialog
title={title}
style={{
width: '400px',
maxHeight: 'min(80dvh, calc(100dvh - 40px))',
overflowY: 'auto',
padding: '32px',
}}
>
{share && (
<ConfirmationForm
message={t.rich(messages.confirmRemove, {
target: share.slug,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm}
onClose={onClose}
buttonLabel={deleteLabel}
buttonVariant="danger"
/>
)}
</Dialog>
</Modal>
</ControlledDialog>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { Icon, Row, Text } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { CopyButton } from '@/components/common/CopyButton';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { ExternalLink } from '@/components/icons';
const desktopGridTemplateColumns = 'minmax(180px, 1fr) minmax(0, 2fr) auto';
const rowBorder = '1px solid var(--border-default)';
export interface ShareRow {
id: string;
name: string;
slug: string;
}
export function SharedSharesTable({
data = [],
renderActions,
}: {
data?: ShareRow[];
renderActions: (row: ShareRow) => ReactNode;
}) {
const { t, labels } = useMessages();
const { cloudMode } = useConfig();
const { isMobile } = useMobile();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<div style={{ width: '100%' }}>
{!isMobile && (
<div
style={{
display: 'grid',
gridTemplateColumns: desktopGridTemplateColumns,
gap: '16px',
alignItems: 'center',
padding: '0 0 12px',
borderBottom: rowBorder,
}}
>
<Text weight="bold">{t(labels.name)}</Text>
<Text weight="bold">{t(labels.shareUrl)}</Text>
<div />
</div>
)}
<div>
{data.map(row => {
const url = getUrl(row.slug);
if (isMobile) {
return (
<div
key={row.id}
style={{
padding: '12px 0',
borderBottom: rowBorder,
}}
>
<Text weight="bold">{row.name}</Text>
<div style={{ marginTop: '8px' }}>
<ShareUrlCell url={url} value={row.slug} />
</div>
<Row justifyContent="flex-end" style={{ marginTop: '12px' }}>
{renderActions(row)}
</Row>
</div>
);
}
return (
<div
key={row.id}
style={{
display: 'grid',
gridTemplateColumns: desktopGridTemplateColumns,
gap: '16px',
alignItems: 'center',
minHeight: '56px',
padding: '12px 0',
borderBottom: rowBorder,
}}
>
<Text>{row.name}</Text>
<ShareUrlCell url={url} value={url} />
<Row justifyContent="flex-end">{renderActions(row)}</Row>
</div>
);
})}
</div>
</div>
);
}
function ShareUrlCell({ url, value }: { url: string; value: string }) {
return (
<Row alignItems="center" gap="1" minWidth="0" width="100%" overflow="hidden">
<Row alignItems="center" gap minWidth="0" style={{ flex: 1 }}>
<Text title={url} style={{ minWidth: 0, flex: 1, overflowWrap: 'anywhere' }}>
<a
href={url}
target="_blank"
rel="noreferrer"
className="underline decoration-edge underline-offset-4 hover:text-foreground-primary hover:decoration-primary"
>
{value}
</a>
</Text>
<Icon size="sm" strokeColor="muted" style={{ flexShrink: 0 }}>
<ExternalLink />
</Icon>
</Row>
<CopyButton value={url} label="Copy URL" />
</Row>
);
}
+6 -47
View File
@@ -1,59 +1,18 @@
import { DataColumn, DataTable, Icon, type DataTableProps, Row, Text } from '@umami/react-zen';
import { ShareDeleteButton } from '@/app/(main)/websites/[websiteId]/settings/ShareDeleteButton';
import { CopyButton } from '@/components/common/CopyButton';
import Link from '@/components/common/Link';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { ExternalLink } from '@/components/icons';
import { Row, type DataTableProps } from '@umami/react-zen';
import { SimpleShareEditButton } from './SimpleShareEditButton';
import { SharedSharesTable } from './SharedSharesTable';
export function SimpleSharesTable(props: DataTableProps) {
const { t, labels } = useMessages();
const config = useConfig();
const cloudMode = config?.cloudMode;
const { isMobile } = useMobile();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
<DataColumn id="name" label={t(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={t(labels.shareUrl)} width="2fr" style={{ minWidth: 0 }}>
{({ slug }: any) => {
const url = getUrl(slug);
return (
<Row alignItems="center" gap="1" minWidth="0" width="100%" overflow="hidden">
<Row alignItems="center" gap minWidth="0" style={{ flex: 1 }}>
<Text title={url} style={{ minWidth: 0, flex: 1, overflowWrap: 'anywhere' }}>
<Link href={url} target="_blank" prefetch={false}>
{isMobile ? slug : url}
</Link>
</Text>
<Icon size="sm" strokeColor="muted" style={{ flexShrink: 0 }}>
<ExternalLink />
</Icon>
</Row>
<CopyButton value={url} label="Copy URL" />
</Row>
);
}}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => (
<SharedSharesTable
data={props.data as any[]}
renderActions={({ id, slug }) => (
<Row>
<SimpleShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
)}
</DataColumn>
</DataTable>
/>
);
}