Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user