feat: add projects management

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

240
app/routes/projects.tsx Normal file
View file

@ -0,0 +1,240 @@
import { useMemo } from 'react';
import {
Button,
Paper,
Text,
Menu,
ActionIcon,
Pagination,
NativeSelect,
Group,
useMantineTheme,
Alert,
ColorSwatch
} from '@mantine/core';
import { json, LoaderArgs, MetaFunction, redirect } from '@remix-run/node';
import {
Form,
Link,
Outlet,
useCatch,
useLoaderData,
useSearchParams
} from '@remix-run/react';
import { AlertTriangle, Edit3, Plus, Settings, Trash } from 'react-feather';
import { requireUserId } from '~/session.server';
import { getProjects } from '~/models/project.server';
export const meta: MetaFunction = () => {
return {
title: 'Projects | WorkTimer',
description: 'Manage your projects. You must be logged in to do this.'
};
};
export async function loader({ request }: LoaderArgs) {
const userId = await requireUserId(request);
if (!userId) return redirect('/login');
const url = new URL(request.url);
const page = url.searchParams.get('page')
? parseInt(url.searchParams.get('page')!, 10)
: 1;
const size = url.searchParams.get('size')
? parseInt(url.searchParams.get('size')!, 10)
: 25;
const orderBy = url.searchParams.get('orderBy') || 'createdAt';
const order = url.searchParams.get('order') || 'desc';
return json({
...(await getProjects({
page,
size,
userId,
orderBy,
order: order === 'asc' ? 'asc' : 'desc'
}))
});
}
export default function Projects() {
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const theme = useMantineTheme();
const pageSize = useMemo(() => {
return parseInt(searchParams.get('size') || '25', 10);
}, [searchParams]);
const page = useMemo(() => {
return parseInt(searchParams.get('page') || '1', 10);
}, [searchParams]);
return (
<div>
<Paper
component="fieldset"
aria-controls="projects"
p="sm"
shadow="sm"
radius="md"
withBorder
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap'
}}
>
<Button
component={Link}
to="/projects/new"
variant="light"
radius={theme.radius.md}
leftIcon={<Plus />}
>
New project
</Button>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between'
}}
>
<NativeSelect
data={[
{ label: '25 / page', value: '25' },
{ label: '50 / page', value: '50' },
{ label: '100 / page', value: '100' }
]}
value={pageSize}
onChange={(event) => {
setSearchParams({
page: page.toString(),
size: event.currentTarget.value
});
}}
/>
{data.total / pageSize > 1 && (
<Pagination
style={{ marginLeft: 10 }}
page={page}
total={Math.ceil(data.total / pageSize)}
onChange={(page) => {
setSearchParams({
page: page.toString(),
size: pageSize.toString()
});
}}
/>
)}
</div>
</Paper>
<Group mt="lg" mb="md" mx="auto">
<Text size="sm" color="darkgray">
{data.total} entries
</Text>
</Group>
<div role="region" id="projects">
{data.projects.map((project) => (
<Paper
key={project.id}
shadow="sm"
p="md"
radius="md"
mb="sm"
display="flex"
style={{
alignItems: 'center',
flexDirection: 'column'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%'
}}
>
<ColorSwatch color={project.color} />
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
flexDirection: 'column',
marginRight: 'auto',
marginLeft: '1rem'
}}
>
<strong>{project.name}</strong>
<span
style={{
fontSize: '0.8em'
}}
>
{project.description}
</span>
</div>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon title="Edit" mr="xs">
<Settings size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Edit project</Menu.Label>
<Menu.Item
component={Link}
to={`/projects/${project.id}`}
icon={<Edit3 size={14} color={theme.colors.yellow[8]} />}
>
Edit
</Menu.Item>
<Form method="delete" action={`/projects/${project.id}`}>
<Menu.Item
component="button"
type="submit"
icon={<Trash size={14} color={theme.colors.red[8]} />}
>
Delete
</Menu.Item>
</Form>
</Menu.Dropdown>
</Menu>
</div>
</Paper>
))}
</div>
<Outlet />
</div>
);
}
export function ErrorBoundary({ error }: { error: Error }) {
console.error(error);
return (
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
An unexpected error occurred: {error.message}
</Alert>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 404) {
return (
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
Not found
</Alert>
);
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
}

View file

@ -0,0 +1,274 @@
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 {
Alert,
Drawer,
TextInput,
Text,
useMantineTheme,
Group,
Button,
Textarea,
Stack,
Select,
ColorSwatch,
ColorInput,
ActionIcon,
Input,
ColorPicker
} from '@mantine/core';
import {
AlertTriangle,
Delete,
Play,
RefreshCcw,
Save,
Square,
Trash
} from 'react-feather';
import invariant from 'tiny-invariant';
import {
deleteTimeEntry,
getTimeEntry,
updateTimeEntry
} from '~/models/timeEntry.server';
import { requireUserId } from '~/session.server';
import {
deleteProject,
getProject,
getProjects,
Project,
updateProject
} from '~/models/project.server';
import { DatePicker, TimeInput } from '@mantine/dates';
export const meta: MetaFunction = () => {
return {
title: 'Edit Project | WorkTimer',
description: 'Edit a project. You must be logged in to do this.'
};
};
export async function loader({ request, params }: LoaderArgs) {
const userId = await requireUserId(request);
invariant(params.projectId, 'projectId not found');
const project = await getProject({ userId, id: params.projectId });
if (!project) {
throw new Response('Not Found', { status: 404 });
}
return json({ project });
}
export async function action({ request, params }: ActionArgs) {
const userId = await requireUserId(request);
invariant(params.projectId, 'projectId not found');
const project = await getProject({ userId, id: params.projectId });
if (!project) {
throw new Response('Not Found', { status: 404 });
}
if (request.method === 'DELETE') {
await deleteProject({ userId, id: params.projectId });
} else if (request.method === 'PATCH') {
const formData = await request.formData();
const name = (formData.get('name') || undefined) as string | undefined;
const description = (formData.get('description') || undefined) as
| string
| undefined;
let color = (formData.get('color') || undefined) as string | undefined;
await updateProject({
projectId: params.projectId,
name,
description,
color
});
}
return redirect('/projects');
}
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const theme = useMantineTheme();
const navigate = useNavigate();
return (
<Drawer
opened
position="right"
title="Edit Project"
padding="xl"
size="xl"
overlayColor={
theme.colorScheme === 'dark'
? theme.colors.dark[9]
: theme.colors.gray[2]
}
overlayOpacity={0.55}
overlayBlur={3}
onClose={() => {
navigate('/projects');
}}
>
{children}
</Drawer>
);
};
const randomColor = () =>
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
export default function ProjectDetailsPage() {
const actionData = useActionData<typeof action>();
const data = useLoaderData<typeof loader>();
const theme = useMantineTheme();
const nameRef = React.useRef<HTMLInputElement>(null);
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
const colorRef = React.useRef<HTMLInputElement>(null);
const [color, setColor] = React.useState<Project['color']>(
data.project.color || randomColor()
);
return (
<LayoutWrapper>
<Form
method="patch"
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%'
}}
>
<TextInput
mb={12}
withAsterisk
label="Name"
placeholder="The name of your project"
id="new-name"
ref={nameRef}
required
autoFocus={true}
name="name"
defaultValue={data.project.name}
/>
<Textarea
mb={12}
label="Description"
placeholder="What is this project about?"
id="new-description"
ref={descriptionRef}
name="description"
defaultValue={data.project.description || undefined}
/>
<Input.Wrapper
label="Color"
placeholder="The color of your project"
id="new-color"
withAsterisk
required
>
<Group>
<ColorSwatch color={color} size={40} />
<Group
style={{
gap: 8,
maxWidth: 200
}}
>
{Object.keys(theme.colors)
.filter((c) => c !== 'dark' && c !== 'grape')
.map((c) => (
<ColorSwatch
color={c}
key={c}
onClick={() => {
setColor(c);
}}
style={{
cursor: 'pointer',
border: `1px solid ${
c === color ? 'white' : 'transparent'
}`
}}
/>
))}
</Group>
</Group>
<input type="hidden" ref={colorRef} name="color" value={color} />
</Input.Wrapper>
<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>
</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}`);
}

248
app/routes/projects/new.tsx Normal file
View file

@ -0,0 +1,248 @@
import {
Drawer,
TextInput,
useMantineTheme,
Group,
Button,
Textarea,
ColorInput,
Alert,
ActionIcon
} 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, RefreshCcw, Save } from 'react-feather';
import { createProject, Project } from '~/models/project.server';
import { requireUserId } from '~/session.server';
export const meta: MetaFunction = () => {
return {
title: 'New Project | WorkTimer',
description: 'Create a new project. You must be logged in to do this.'
};
};
export async function loader({ request }: LoaderArgs) {
const userId = await requireUserId(request);
if (!userId) return redirect('/projects');
return json({});
}
export async function action({ request }: ActionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const name = formData.get('name');
const description = formData.get('description');
const color = formData.get('color');
if (typeof name !== 'string' || name.length === 0) {
return json(
{
errors: {
name: 'name is required',
description: null,
color: null
}
},
{ status: 400 }
);
}
if (description && typeof description !== 'string') {
return json(
{
errors: {
name: null,
description: 'Description is invalid',
color: null
}
},
{ status: 422 }
);
}
if (typeof color !== 'string' || color.length === 0) {
return json(
{
errors: {
name: null,
description: null,
color: 'color is required'
}
},
{ status: 400 }
);
}
const project = await createProject({
name,
description,
color,
userId
});
return redirect(`/projects`);
}
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const theme = useMantineTheme();
const navigate = useNavigate();
return (
<Drawer
opened
position="right"
title="New Project"
padding="xl"
size="xl"
overlayColor={
theme.colorScheme === 'dark'
? theme.colors.dark[9]
: theme.colors.gray[2]
}
overlayOpacity={0.55}
overlayBlur={3}
onClose={() => {
navigate('/projects');
}}
>
{children}
</Drawer>
);
};
const randomColor = () =>
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
export default function NewProjectPage() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const theme = useMantineTheme();
const nameRef = React.useRef<HTMLInputElement>(null);
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
const colorRef = React.useRef<HTMLInputElement>(null);
const [color, setColor] = React.useState<Project['color']>(randomColor());
React.useEffect(() => {
if (actionData?.errors?.name) {
nameRef.current?.focus();
} else if (actionData?.errors?.description) {
descriptionRef.current?.focus();
} else if (actionData?.errors?.color) {
colorRef.current?.focus();
}
}, [actionData]);
return (
<LayoutWrapper>
<Form
method="post"
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%'
}}
>
<TextInput
mb={12}
withAsterisk
label="Name"
placeholder="The name of your project"
id="new-name"
ref={nameRef}
required
autoFocus={true}
name="name"
aria-invalid={actionData?.errors?.name ? true : undefined}
error={actionData?.errors?.name}
errorProps={{ children: actionData?.errors?.name }}
/>
<Textarea
mb={12}
label="Description"
placeholder="What is this project about?"
id="new-description"
ref={descriptionRef}
name="description"
aria-invalid={actionData?.errors?.description ? true : undefined}
error={actionData?.errors?.description}
errorProps={{ children: actionData?.errors?.description }}
/>
<ColorInput
label="Color"
placeholder="The color of your project"
id="new-color"
name="color"
ref={colorRef}
withPicker={false}
withEyeDropper
withAsterisk
swatchesPerRow={6}
swatches={Object.keys(theme.colors).map(
(color) => theme.colors[color][6]
)}
rightSection={
<ActionIcon onClick={() => setColor(randomColor())}>
<RefreshCcw size={16} />
</ActionIcon>
}
value={color}
onChange={setColor}
closeOnColorSwatchClick
format="hex"
required
aria-invalid={actionData?.errors?.color ? true : undefined}
error={actionData?.errors?.color}
errorProps={{ children: actionData?.errors?.color }}
/>
<Group position="left" mt="lg">
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>
Create
</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}`);
}