feat: add new + edit/delete time-entries

This commit is contained in:
Nicola Zambello 2023-02-14 10:27:27 +01:00
parent 81273a8a9a
commit 9b16fe617c
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
3 changed files with 874 additions and 128 deletions

View file

@ -1,60 +1,496 @@
import type { ActionArgs, LoaderArgs } from '@remix-run/node' import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node' import { json, redirect } from '@remix-run/node';
import { Form, useCatch, useLoaderData } from '@remix-run/react' import {
import invariant from 'tiny-invariant' Form,
useActionData,
useCatch,
useLoaderData,
useNavigate
} from '@remix-run/react';
import * as React from 'react';
import {
Alert,
Drawer,
TextInput,
Text,
useMantineTheme,
Group,
Button,
Textarea,
Stack,
Select,
ColorSwatch,
ActionIcon
} from '@mantine/core';
import {
AlertTriangle,
Delete,
Play,
Save,
Square,
Trash
} from 'react-feather';
import invariant from 'tiny-invariant';
import { deleteNote, getNote } from '~/models/note.server' import {
import { requireUserId } from '~/session.server' deleteTimeEntry,
getTimeEntry,
updateTimeEntry
} from '~/models/timeEntry.server';
import { requireUserId } from '~/session.server';
import { getProjects } from '~/models/project.server';
import { DatePicker, TimeInput } from '@mantine/dates';
export const meta: MetaFunction = () => {
return {
title: 'Edit Time Entry | WorkTimer',
description: 'Edit a time entry. You must be logged in to do this.'
};
};
export async function loader({ request, params }: LoaderArgs) { export async function loader({ request, params }: LoaderArgs) {
const userId = await requireUserId(request) const userId = await requireUserId(request);
invariant(params.noteId, 'noteId not found') invariant(params.timeEntryId, 'timeEntryId not found');
const note = await getNote({ userId, id: params.timeEntryId }) const timeEntry = await getTimeEntry({ userId, id: params.timeEntryId });
if (!note) { if (!timeEntry) {
throw new Response('Not Found', { status: 404 }) throw new Response('Not Found', { status: 404 });
} }
return json({ note })
const projects = await getProjects({ userId });
return json({ timeEntry, projects });
} }
export async function action({ request, params }: ActionArgs) { export async function action({ request, params }: ActionArgs) {
const userId = await requireUserId(request) const userId = await requireUserId(request);
invariant(params.timeEntryId, 'timeEntryId not found') invariant(params.timeEntryId, 'timeEntryId not found');
await deleteNote({ userId, id: params.timeEntryId }) const timeEntry = await getTimeEntry({ userId, id: params.timeEntryId });
if (!timeEntry) {
return redirect('/notes') throw new Response('Not Found', { status: 404 });
} }
export default function NoteDetailsPage() { if (request.method === 'DELETE') {
const data = useLoaderData<typeof loader>() await deleteTimeEntry({ userId, id: params.timeEntryId });
} else if (request.method === 'PATCH') {
const formData = await request.formData();
return ( const description = (formData.get('description') || undefined) as
<div> | string
<h3 className="text-2xl font-bold">{data.note.title}</h3> | undefined;
<p className="py-6">{data.note.body}</p> const projectId = (formData.get('projectId') || undefined) as
<hr className="my-4" /> | string
<Form method="post"> | undefined;
<button type="submit" className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"> let startTime = (formData.get('startTime') || undefined) as
Delete | string
</button> | undefined;
</Form> let endTime = (formData.get('endTime') || undefined) as string | undefined;
if (
startTime &&
typeof startTime === 'string' &&
Number.isNaN(Date.parse(startTime))
) {
return json(
{
errors: {
projectId: null,
description: null,
startTime: 'startTime is invalid',
endTime: null
}
},
{ status: 422 }
);
}
if (
endTime &&
typeof endTime === 'string' &&
Number.isNaN(Date.parse(endTime))
) {
return json(
{
errors: {
projectId: null,
description: null,
startTime: null,
endTime: 'endTime is invalid'
}
},
{ status: 422 }
);
}
if (
startTime &&
endTime &&
typeof startTime === 'string' &&
typeof endTime === 'string' &&
new Date(startTime) > new Date(endTime)
) {
return json(
{
errors: {
projectId: null,
description: null,
startTime: 'startTime must be before endTime',
endTime: 'startTime must be before endTime'
}
},
{ status: 422 }
);
}
await updateTimeEntry({
timeEntryId: params.timeEntryId,
description,
projectId,
startTime: startTime ? new Date(startTime) : undefined,
endTime: endTime ? new Date(endTime) : undefined
});
}
return redirect('/time-entries');
}
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
id: string;
label: string;
color: string;
}
const SelectItem = React.forwardRef<HTMLDivElement, ItemProps>(
({ label, color, id, ...others }: ItemProps, ref) => (
<div key={id} ref={ref} {...others}>
<Group noWrap>
<ColorSwatch color={color} />
<Text size="sm">{label}</Text>
</Group>
</div> </div>
) )
);
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const theme = useMantineTheme();
const navigate = useNavigate();
return (
<Drawer
opened
position="right"
title="Edit Time Entry"
padding="xl"
size="xl"
overlayColor={
theme.colorScheme === 'dark'
? theme.colors.dark[9]
: theme.colors.gray[2]
}
overlayOpacity={0.55}
overlayBlur={3}
onClose={() => {
navigate('/time-entries');
}}
>
{children}
</Drawer>
);
};
export default function TimeEntryDetailsPage() {
const actionData = useActionData<typeof action>();
const data = useLoaderData<typeof loader>();
const theme = useMantineTheme();
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
const startDateRef = React.useRef<HTMLInputElement>(null);
const endDateRef = React.useRef<HTMLInputElement>(null);
const projectRef = React.useRef<HTMLInputElement>(null);
const [start, setStart] = React.useState<Date>(
new Date(data.timeEntry.startTime || Date.now())
);
const [end, setEnd] = React.useState<Date | undefined>(
data.timeEntry.endTime ? new Date(data.timeEntry.endTime) : undefined
);
React.useEffect(() => {
if (actionData?.errors?.description) {
descriptionRef.current?.focus();
} else if (actionData?.errors?.startTime) {
startDateRef.current?.focus();
} else if (actionData?.errors?.endTime) {
endDateRef.current?.focus();
} else if (actionData?.errors?.projectId) {
projectRef.current?.focus();
}
}, [actionData]);
return (
<LayoutWrapper>
<Form
method="post"
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%'
}}
>
<Textarea
mb={12}
withAsterisk
label="Description"
placeholder="What are you working on?"
id="new-description"
ref={descriptionRef}
defaultValue={data.timeEntry.description}
required
autoFocus={true}
name="description"
aria-invalid={actionData?.errors?.description ? true : undefined}
error={actionData?.errors?.description}
errorProps={{ children: actionData?.errors?.description }}
/>
<Select
id="new-project"
ref={projectRef}
name="projectId"
mb={12}
label="Project"
defaultValue={data.timeEntry.projectId}
placeholder="Select project"
searchable
nothingFound="No options"
required
withAsterisk
maxDropdownHeight={400}
data={data.projects.projects.map((project) => ({
label: project.name,
value: project.id,
color: project.color
}))}
itemComponent={SelectItem}
filter={(value, item) =>
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
item.value.toLowerCase().includes(value.toLowerCase().trim())
}
aria-invalid={actionData?.errors?.projectId ? true : undefined}
error={actionData?.errors?.projectId}
errorProps={{ children: actionData?.errors?.projectId }}
/>
<Stack>
<label
htmlFor="new-startTime"
style={{
margin: '1rem 0 -0.5rem 0'
}}
>
Start
</label>
<Group align="flex-end">
<DatePicker
id="new-startTime-date"
ref={startDateRef}
name="startTime-date"
allowFreeInput
withAsterisk
clearable={false}
inputFormat="DD/MM/YYYY"
labelFormat="MM/YYYY"
aria-labelledby="new-startTime-label"
locale="it"
placeholder="Start date"
label="Start date"
aria-invalid={actionData?.errors?.startTime ? true : undefined}
error={actionData?.errors?.startTime}
errorProps={{ children: actionData?.errors?.startTime }}
value={start}
onChange={(date) => {
if (!date) return;
let newDate = new Date(start);
newDate.setFullYear(date.getFullYear());
newDate.setMonth(date.getMonth());
newDate.setDate(date.getDate());
setStart(newDate);
}}
/>
<TimeInput
id="new-startTime-time"
ref={startDateRef}
name="startTime-time"
withAsterisk
withSeconds
clearable={false}
aria-labelledby="new-startTime-label"
label="Start time"
value={start}
onChange={(date) => {
let newDate = new Date(start);
newDate.setHours(date.getHours());
newDate.setMinutes(date.getMinutes());
setStart(newDate);
}}
aria-invalid={actionData?.errors?.startTime ? true : undefined}
error={actionData?.errors?.startTime}
errorProps={{ children: actionData?.errors?.startTime }}
/>
</Group>
<input type="hidden" name="startTime" value={start.toISOString()} />
</Stack>
<Stack>
<label
htmlFor="new-endTime"
style={{
margin: '1rem 0 -0.5rem 0'
}}
>
End
</label>
<Group align="flex-end">
<DatePicker
id="new-endTime-date"
ref={endDateRef}
name="endTime-date"
allowFreeInput
clearable={true}
inputFormat="DD/MM/YYYY"
labelFormat="MM/YYYY"
aria-labelledby="new-endTime-label"
locale="it"
placeholder="End date"
label="End date"
aria-invalid={actionData?.errors?.endTime ? true : undefined}
error={actionData?.errors?.endTime}
errorProps={{ children: actionData?.errors?.endTime }}
value={end}
onChange={(date) => {
if (!date) return;
let newDate = new Date(end || start);
newDate.setFullYear(date.getFullYear());
newDate.setMonth(date.getMonth());
newDate.setDate(date.getDate());
setEnd(newDate);
}}
/>
<TimeInput
id="new-endTime-time"
ref={endDateRef}
name="endTime-time"
withAsterisk
withSeconds
clearable={false}
aria-labelledby="new-endTime-label"
label="end time"
value={end}
onChange={(date) => {
let newDate = new Date(end || start);
newDate.setHours(date.getHours());
newDate.setMinutes(date.getMinutes());
setEnd(newDate);
}}
aria-invalid={actionData?.errors?.endTime ? true : undefined}
error={actionData?.errors?.endTime}
errorProps={{ children: actionData?.errors?.endTime }}
/>
</Group>
{end && (
<input type="hidden" name="endTime" value={end.toISOString()} />
)}
</Stack>
<Group position="left" mt="lg">
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>
Save
</Button>
</Group>
</Form>
<section style={{ marginTop: '1rem', marginBottom: '1rem' }}>
<Form method="delete">
<input
type="hidden"
name="endTime"
value={new Date().toISOString()}
/>
<Button
type="submit"
variant="outline"
color="red"
leftIcon={<Trash />}
radius={theme.radius.md}
>
Delete
</Button>
</Form>
</section>
{!end && (
<section style={{ marginTop: '1rem', marginBottom: '1rem' }}>
<Form method="patch">
<input
type="hidden"
name="endTime"
value={new Date().toISOString()}
/>
<Button
type="submit"
variant="subtle"
radius={theme.radius.md}
leftIcon={
<div
style={{
backgroundColor: theme.colors.red[7],
color: 'white',
width: '1.75rem',
height: '1.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%'
}}
>
<Square size={12} fill="currentColor" />
</div>
}
>
Stop running time entry
</Button>
</Form>
</section>
)}
</LayoutWrapper>
);
} }
export function ErrorBoundary({ error }: { error: Error }) { export function ErrorBoundary({ error }: { error: Error }) {
console.error(error) console.error(error);
return <div>An unexpected error occurred: {error.message}</div> return (
<LayoutWrapper>
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
An unexpected error occurred: {error.message}
</Alert>
</LayoutWrapper>
);
} }
export function CatchBoundary() { export function CatchBoundary() {
const caught = useCatch() const caught = useCatch();
if (caught.status === 404) { if (caught.status === 404) {
return <div>Note not found</div> return (
<LayoutWrapper>
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
Not found
</Alert>
</LayoutWrapper>
);
} }
throw new Error(`Unexpected caught response with status: ${caught.status}`) throw new Error(`Unexpected caught response with status: ${caught.status}`);
} }

View file

@ -1,12 +0,0 @@
import { Link } from '@remix-run/react'
export default function NoteIndexPage() {
return (
<p>
No note selected. Select a note on the left, or{' '}
<Link to="new" className="text-blue-500 underline">
create a new note.
</Link>
</p>
)
}

View file

@ -1,36 +1,132 @@
import type { ActionArgs } from '@remix-run/node' import {
import { json, redirect } from '@remix-run/node' Alert,
import { Form, useActionData } from '@remix-run/react' Drawer,
import * as React from 'react' TextInput,
import z from 'zod' Text,
useMantineTheme,
Group,
Button,
Textarea,
Stack,
Select,
ColorSwatch
} from '@mantine/core';
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import {
Form,
useActionData,
useCatch,
useLoaderData,
useNavigate
} from '@remix-run/react';
import * as React from 'react';
import { AlertTriangle, Play } from 'react-feather';
import { getProjects } from '~/models/project.server';
import { createTimeEntry, stopAllTimeEntries } from '~/models/timeEntry.server';
import { requireUserId } from '~/session.server';
import { DatePicker, TimeInput } from '@mantine/dates';
import { forwardRef } from 'react';
import { createTimeEntry } from '~/models/timeEntry.server' import 'dayjs/locale/it';
import { requireUserId } from '~/session.server'
export const meta: MetaFunction = () => {
return {
title: 'New Time Entry | WorkTimer',
description: 'Create a new time entry. You must be logged in to do this.'
};
};
export async function loader({ request }: LoaderArgs) {
const userId = await requireUserId(request);
return json({
...(await getProjects({ userId }))
});
}
export async function action({ request }: ActionArgs) { export async function action({ request }: ActionArgs) {
const userId = await requireUserId(request) const userId = await requireUserId(request);
const formData = await request.formData() const formData = await request.formData();
const description = formData.get('description') const description = formData.get('description');
const projectId = formData.get('projectId') const projectId = formData.get('projectId');
let startTime = formData.get('startTime') let startTime = formData.get('startTime');
let endTime = formData.get('endTime') let endTime = formData.get('endTime');
if (typeof description !== 'string' || description.length === 0) { if (typeof description !== 'string' || description.length === 0) {
return json({ errors: { description: 'Description is required' } }, { status: 400 }) return json(
{
errors: {
projectId: null,
description: 'Description is required',
startTime: null,
endTime: null
}
},
{ status: 400 }
);
} }
if (typeof projectId !== 'string' || projectId.length === 0) { if (typeof projectId !== 'string' || projectId.length === 0) {
return json({ errors: { projectId: 'projectId is required' } }, { status: 400 }) return json(
{
errors: {
projectId: 'projectId is required',
description: null,
startTime: null,
endTime: null
}
},
{ status: 400 }
);
} }
if (typeof startTime !== 'string' || startTime.length === 0) { if (typeof startTime !== 'string' || startTime.length === 0) {
return json({ errors: { startTime: 'startTime is required' } }, { status: 400 }) return json(
{
errors: {
projectId: null,
description: null,
startTime: 'startTime is required',
endTime: null
}
},
{ status: 400 }
);
} }
if (startTime && typeof startTime === 'string' && Number.isNaN(Date.parse(startTime))) { if (
return json({ errors: { startTime: 'startTime is invalid' } }, { status: 422 }) startTime &&
typeof startTime === 'string' &&
Number.isNaN(Date.parse(startTime))
) {
return json(
{
errors: {
projectId: null,
description: null,
startTime: 'startTime is invalid',
endTime: null
} }
if (endTime && typeof endTime === 'string' && Number.isNaN(Date.parse(endTime))) { },
return json({ errors: { endTime: 'endTime is invalid' } }, { status: 422 }) { status: 422 }
);
}
if (
endTime &&
typeof endTime === 'string' &&
Number.isNaN(Date.parse(endTime))
) {
return json(
{
errors: {
projectId: null,
description: null,
startTime: null,
endTime: 'endTime is invalid'
}
},
{ status: 422 }
);
} }
if ( if (
startTime && startTime &&
@ -39,8 +135,20 @@ export async function action({ request }: ActionArgs) {
typeof endTime === 'string' && typeof endTime === 'string' &&
new Date(startTime) > new Date(endTime) new Date(startTime) > new Date(endTime)
) { ) {
return json({ errors: { endTime: 'startTime must be before endTime' } }, { status: 422 }) return json(
{
errors: {
projectId: null,
description: null,
startTime: 'startTime must be before endTime',
endTime: 'startTime must be before endTime'
} }
},
{ status: 422 }
);
}
await stopAllTimeEntries(userId);
const timeEntry = await createTimeEntry({ const timeEntry = await createTimeEntry({
description, description,
@ -48,27 +156,86 @@ export async function action({ request }: ActionArgs) {
endTime: typeof endTime === 'string' ? new Date(endTime) : null, endTime: typeof endTime === 'string' ? new Date(endTime) : null,
userId, userId,
projectId projectId
}) });
return redirect(`/time-entries/${timeEntry.id}`) return redirect(`/time-entries`);
} }
export default function NewNotePage() { interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
const actionData = useActionData<typeof action>() id: string;
const titleRef = React.useRef<HTMLInputElement>(null) label: string;
const bodyRef = React.useRef<HTMLTextAreaElement>(null) color: string;
React.useEffect(() => {
if (actionData?.errors?.title) {
titleRef.current?.focus()
} else if (actionData?.errors?.body) {
bodyRef.current?.focus()
} }
}, [actionData])
const SelectItem = forwardRef<HTMLDivElement, ItemProps>(
({ label, color, id, ...others }: ItemProps, ref) => (
<div key={id} ref={ref} {...others}>
<Group noWrap>
<ColorSwatch color={color} />
<Text size="sm">{label}</Text>
</Group>
</div>
)
);
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const theme = useMantineTheme();
const navigate = useNavigate();
return ( return (
<Drawer
opened
position="right"
title="New Time Entry"
padding="xl"
size="xl"
overlayColor={
theme.colorScheme === 'dark'
? theme.colors.dark[9]
: theme.colors.gray[2]
}
overlayOpacity={0.55}
overlayBlur={3}
onClose={() => {
navigate('/time-entries');
}}
>
{children}
</Drawer>
);
};
export default function NewTimeEntryPage() {
const actionData = useActionData<typeof action>();
const data = useLoaderData<typeof loader>();
const theme = useMantineTheme();
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
const startDateRef = React.useRef<HTMLInputElement>(null);
const endDateRef = React.useRef<HTMLInputElement>(null);
const projectRef = React.useRef<HTMLInputElement>(null);
const [start, setStart] = React.useState(new Date(Date.now()));
const [end, setEnd] = React.useState<Date | undefined>();
React.useEffect(() => {
if (actionData?.errors?.description) {
descriptionRef.current?.focus();
} else if (actionData?.errors?.startTime) {
startDateRef.current?.focus();
} else if (actionData?.errors?.endTime) {
endDateRef.current?.focus();
} else if (actionData?.errors?.projectId) {
projectRef.current?.focus();
}
}, [actionData]);
return (
<LayoutWrapper>
<Form <Form
method="post" method="post"
noValidate
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -76,48 +243,203 @@ export default function NewNotePage() {
width: '100%' width: '100%'
}} }}
> >
<div> <Textarea
<label className="flex w-full flex-col gap-1"> mb={12}
<span>Title: </span> withAsterisk
<input label="Description"
ref={titleRef} placeholder="What are you working on?"
name="title" id="new-description"
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose" ref={descriptionRef}
aria-invalid={actionData?.errors?.title ? true : undefined} required
aria-errormessage={actionData?.errors?.title ? 'title-error' : undefined} autoFocus={true}
name="description"
aria-invalid={actionData?.errors?.description ? true : undefined}
error={actionData?.errors?.description}
errorProps={{ children: actionData?.errors?.description }}
/> />
</label>
{actionData?.errors?.title && (
<div className="pt-1 text-red-700" id="title-error">
{actionData.errors.title}
</div>
)}
</div>
<div> <Select
<label className="flex w-full flex-col gap-1"> id="new-project"
<span>Body: </span> ref={projectRef}
<textarea name="projectId"
ref={bodyRef} mb={12}
name="body" label="Project"
rows={8} placeholder="Select project"
className="w-full flex-1 rounded-md border-2 border-blue-500 py-2 px-3 text-lg leading-6" searchable
aria-invalid={actionData?.errors?.body ? true : undefined} nothingFound="No options"
aria-errormessage={actionData?.errors?.body ? 'body-error' : undefined} required
/> withAsterisk
</label> maxDropdownHeight={400}
{actionData?.errors?.body && ( data={data.projects.map((project) => ({
<div className="pt-1 text-red-700" id="body-error"> label: project.name,
{actionData.errors.body} value: project.id,
</div> color: project.color
)} }))}
</div> itemComponent={SelectItem}
filter={(value, item) =>
<div className="text-right"> item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
<button type="submit" className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"> item.value.toLowerCase().includes(value.toLowerCase().trim())
Save }
</button> aria-invalid={actionData?.errors?.projectId ? true : undefined}
</div> error={actionData?.errors?.projectId}
</Form> errorProps={{ children: actionData?.errors?.projectId }}
) />
<Stack>
<label
htmlFor="new-startTime"
style={{
margin: '1rem 0 -0.5rem 0'
}}
>
Start
</label>
<Group align="flex-end">
<DatePicker
id="new-startTime-date"
ref={startDateRef}
name="startTime-date"
allowFreeInput
withAsterisk
clearable={false}
inputFormat="DD/MM/YYYY"
labelFormat="MM/YYYY"
aria-labelledby="new-startTime-label"
locale="it"
placeholder="Start date"
label="Start date"
aria-invalid={actionData?.errors?.startTime ? true : undefined}
error={actionData?.errors?.startTime}
errorProps={{ children: actionData?.errors?.startTime }}
value={start}
onChange={(date) => {
if (!date) return;
let newDate = new Date(start);
newDate.setFullYear(date.getFullYear());
newDate.setMonth(date.getMonth());
newDate.setDate(date.getDate());
setStart(newDate);
}}
/>
<TimeInput
id="new-startTime-time"
ref={startDateRef}
name="startTime-time"
withAsterisk
withSeconds
clearable={false}
aria-labelledby="new-startTime-label"
label="Start time"
value={start}
onChange={(date) => {
let newDate = new Date(start);
newDate.setHours(date.getHours());
newDate.setMinutes(date.getMinutes());
setStart(newDate);
}}
aria-invalid={actionData?.errors?.startTime ? true : undefined}
error={actionData?.errors?.startTime}
errorProps={{ children: actionData?.errors?.startTime }}
/>
</Group>
<input type="hidden" name="startTime" value={start.toISOString()} />
</Stack>
<Stack>
<label
htmlFor="new-endTime"
style={{
margin: '1rem 0 -0.5rem 0'
}}
>
End
</label>
<Group align="flex-end">
<DatePicker
id="new-endTime-date"
ref={endDateRef}
name="endTime-date"
allowFreeInput
clearable={true}
inputFormat="DD/MM/YYYY"
labelFormat="MM/YYYY"
aria-labelledby="new-endTime-label"
locale="it"
placeholder="End date"
label="End date"
aria-invalid={actionData?.errors?.endTime ? true : undefined}
error={actionData?.errors?.endTime}
errorProps={{ children: actionData?.errors?.endTime }}
value={end}
onChange={(date) => {
if (!date) return;
let newDate = new Date(end || start);
newDate.setFullYear(date.getFullYear());
newDate.setMonth(date.getMonth());
newDate.setDate(date.getDate());
setEnd(newDate);
}}
/>
<TimeInput
id="new-endTime-time"
ref={endDateRef}
name="endTime-time"
withAsterisk
withSeconds
clearable={false}
aria-labelledby="new-endTime-label"
label="end time"
value={end}
onChange={(date) => {
let newDate = new Date(end || start);
newDate.setHours(date.getHours());
newDate.setMinutes(date.getMinutes());
setEnd(newDate);
}}
aria-invalid={actionData?.errors?.endTime ? true : undefined}
error={actionData?.errors?.endTime}
errorProps={{ children: actionData?.errors?.endTime }}
/>
</Group>
{end && (
<input type="hidden" name="endTime" value={end.toISOString()} />
)}
</Stack>
<Group position="left" mt="lg">
<Button type="submit" leftIcon={<Play />} radius={theme.radius.md}>
Start
</Button>
</Group>
</Form>
</LayoutWrapper>
);
}
export function ErrorBoundary({ error }: { error: Error }) {
console.error(error);
return (
<LayoutWrapper>
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
An unexpected error occurred: {error.message}
</Alert>
</LayoutWrapper>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 404) {
return (
<LayoutWrapper>
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
Not found
</Alert>
</LayoutWrapper>
);
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
} }