Pixel/links development. New validations folder. More refactoring.

This commit is contained in:
Mike Cao 2025-08-14 23:48:11 -07:00
parent 88639dfe83
commit 247e14646b
136 changed files with 1395 additions and 516 deletions

View file

@ -1,17 +1,16 @@
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import { useMessages, useModified } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen';
import { Plus } from '@/components/icons';
import { PixelAddForm } from './PixelAddForm';
import { PixelEditForm } from './PixelEditForm';
export function PixelAddButton() {
export function PixelAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('boards');
touch('pixels');
};
return (
@ -23,8 +22,8 @@ export function PixelAddButton() {
<Text>{formatMessage(labels.addPixel)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addPixel)} style={{ width: 400 }}>
{({ close }) => <PixelAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
<Dialog title={formatMessage(labels.addPixel)} style={{ width: 600 }}>
{({ close }) => <PixelEditForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>

View file

@ -1,62 +0,0 @@
import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen';
import { useApi } from '@/components/hooks';
import { DOMAIN_REGEX } from '@/lib/constants';
import { useMessages } from '@/components/hooks';
export function PixelAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/pixels', { ...data, teamId }),
});
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.domain)}
name="domain"
rules={{
required: formatMessage(labels.required),
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
}}
>
<TextField autoComplete="off" />
</FormField>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -0,0 +1,55 @@
import { Dialog } from '@umami/react-zen';
import { ActionButton } from '@/components/input/ActionButton';
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { messages } from '@/components/messages';
import { useApi, useMessages, useModified } from '@/components/hooks';
export function PixelDeleteButton({
pixelId,
websiteId,
name,
onSave,
}: {
pixelId: string;
websiteId: string;
name: string;
onSave?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { del, useMutation } = useApi();
const { mutate, isPending, error } = useMutation({
mutationFn: () => del(`/websites/${websiteId}/pixels/${pixelId}`),
});
const { touch } = useModified();
const handleConfirm = (close: () => void) => {
mutate(null, {
onSuccess: () => {
touch('pixels');
onSave?.();
close();
},
});
};
return (
<ActionButton title={formatMessage(labels.delete)} icon={<Trash />}>
<Dialog title={formatMessage(labels.confirm)} style={{ width: 400 }}>
{({ close }) => (
<ConfirmationForm
message={formatMessage(messages.confirmRemove, {
target: name,
})}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
/>
)}
</Dialog>
</ActionButton>
);
}

View file

@ -0,0 +1,19 @@
import { ActionButton } from '@/components/input/ActionButton';
import { Edit } from '@/components/icons';
import { Dialog } from '@umami/react-zen';
import { PixelEditForm } from './PixelEditForm';
import { useMessages } from '@/components/hooks';
export function PixelEditButton({ pixelId }: { pixelId: string }) {
const { formatMessage, labels } = useMessages();
return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.pixel)} style={{ width: 800 }}>
{({ close }) => {
return <PixelEditForm pixelId={pixelId} onClose={close} />;
}}
</Dialog>
</ActionButton>
);
}

View file

@ -0,0 +1,105 @@
import {
Form,
FormField,
FormSubmitButton,
Row,
TextField,
Button,
Text,
Label,
Column,
Icon,
Loading,
} from '@umami/react-zen';
import { useConfig, usePixelQuery } from '@/components/hooks';
import { useMessages } from '@/components/hooks';
import { Refresh } from '@/components/icons';
import { getRandomChars } from '@/lib/crypto';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
const generateId = () => getRandomChars(9);
export function PixelEditForm({
pixelId,
teamId,
onSave,
onClose,
}: {
pixelId?: string;
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { mutate, error, isPending } = useUpdateQuery('/pixels', { id: pixelId, teamId });
const { pixelDomain } = useConfig();
const { data, isLoading } = usePixelQuery(pixelId);
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
if (pixelId && !isLoading) {
return <Loading position="page" />;
}
return (
<Form
onSubmit={handleSubmit}
error={error?.message}
defaultValues={{ slug: generateId(), ...data }}
>
{({ setValue }) => {
return (
<>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<Column>
<Label>{formatMessage(labels.pixel)}</Label>
<Row alignItems="center" gap>
<Text>{pixelDomain || window.location.origin}/</Text>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
}}
style={{ width: '100%' }}
>
<TextField autoComplete="off" isReadOnly />
</FormField>
<Button
variant="quiet"
onPress={() => setValue('slug', generateId(), { shouldDirty: true })}
>
<Icon>
<Refresh />
</Icon>
</Button>
</Row>
</Column>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}

View file

@ -0,0 +1,14 @@
import { usePixelsQuery, useNavigation } from '@/components/hooks';
import { PixelsTable } from './PixelsTable';
import { DataGrid } from '@/components/common/DataGrid';
export function PixelsDataTable() {
const { teamId } = useNavigation();
const query = usePixelsQuery({ teamId });
return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <PixelsTable data={data} />}
</DataGrid>
);
}

View file

@ -3,17 +3,23 @@ import { PageBody } from '@/components/common/PageBody';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { PixelAddButton } from './PixelAddButton';
import { useMessages } from '@/components/hooks';
import { useMessages, useNavigation } from '@/components/hooks';
import { PixelsDataTable } from './PixelsDataTable';
import { Panel } from '@/components/common/Panel';
export function PixelsPage() {
const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation();
return (
<PageBody>
<Column>
<Column gap="6">
<PageHeader title={formatMessage(labels.pixels)}>
<PixelAddButton />
<PixelAddButton teamId={teamId} />
</PageHeader>
<Panel>
<PixelsDataTable />
</Panel>
</Column>
</PageBody>
);

View file

@ -0,0 +1,45 @@
import { DataTable, DataColumn, Row } from '@umami/react-zen';
import { useConfig, useMessages, useNavigation } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance';
import { PixelEditButton } from './PixelEditButton';
import { PixelDeleteButton } from './PixelDeleteButton';
import Link from 'next/link';
export function PixelsTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const { websiteId } = useNavigation();
const { pixelsUrl } = useConfig();
const defaultUrl = `${window.location.origin}/p`;
if (data.length === 0) {
return <Empty />;
}
return (
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)} />
<DataColumn id="url" label="URL">
{({ slug }: any) => {
const url = `${pixelsUrl || defaultUrl}/${slug}`;
return <Link href={url}>{url}</Link>;
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{(row: any) => {
const { id, name } = row;
return (
<Row>
<PixelEditButton pixelId={id} />
<PixelDeleteButton pixelId={id} websiteId={websiteId} name={name} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file