feat(project-crud-sync): add planning artifacts for CRUD project lifecycle #5

Open
NHoarau wants to merge 14 commits from feat/create-projects into main
22 changed files with 401 additions and 114 deletions
Showing only changes of commit 568b04ca27 - Show all commits

View file

@ -71,6 +71,7 @@
"testEnvironment": "node",
"moduleNameMapper": {
"^@workspace/schema$": "<rootDir>/../test/__mocks__/schema.ts",
"^@workspace/schema/migrations$": "<rootDir>/../test/__mocks__/schema-migrations.ts",
"^valibot$": "<rootDir>/../test/__mocks__/valibot.ts"
}
}

View file

@ -1,5 +1,6 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Pool } from "pg";
import { migrationsDir } from "@workspace/schema/migrations";
import {
drizzle,
NodePgDatabase,
@ -8,7 +9,6 @@ import {
tasks,
projectsRelations,
tasksRelations,
migrationsDir,
} from "@workspace/schema";
const DEFAULT_PG_PORT = 5432;

View file

@ -5,6 +5,7 @@ const DEFAULT_PORT = 3001;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(process.env.PORT ?? DEFAULT_PORT);
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises

View file

@ -0,0 +1 @@
export const migrationsDir = "/mock/migrations";

View file

@ -8,6 +8,7 @@
},
"moduleNameMapper": {
"^@workspace/schema$": "<rootDir>/__mocks__/schema.ts",
"^@workspace/schema/migrations$": "<rootDir>/__mocks__/schema-migrations.ts",
"^valibot$": "<rootDir>/__mocks__/valibot.ts"
},
"transformIgnorePatterns": [

View file

@ -4,5 +4,8 @@ export default defineAppConfig({
primary: "green",
neutral: "slate",
},
main: {
base: "min-h-0 flex-1 overflow-y-auto",
},
},
});

View file

@ -1,77 +1,64 @@
<script setup>
useHead({
meta: [
{ name: "viewport", content: "width=device-width, initial-scale=1", },
],
link: [
{ rel: "icon", href: "/favicon.ico", },
],
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
link: [{ rel: "icon", href: "/favicon.ico" }],
htmlAttrs: {
lang: "en",
},
},)
});
const title = "Nuxt Starter Template"
const description = "A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
const title = "Work Hub";
const description = "Project management made simple.";
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogImage: "https://ui.nuxt.com/assets/templates/nuxt/starter-light.png",
twitterCard: "summary_large_image",
},)
});
</script>
<template>
<UApp>
<UHeader>
<template #left>
<NuxtLink to="/">
<AppLogo class="w-auto h-6 shrink-0" />
</NuxtLink>
<div class="flex flex-col min-h-screen">
<UHeader>
<template #left>
<NuxtLink to="/">
<AppLogo class="w-auto h-6 shrink-0" />
</NuxtLink>
<TemplateMenu />
</template>
<UButton to="/projects" label="Projects" color="neutral" variant="ghost" />
</template>
<template #right>
<UColorModeButton />
<template #right>
<UColorModeButton />
</template>
</UHeader>
<UButton
to="https://github.com/nuxt-ui-templates/starter"
target="_blank"
icon="i-simple-icons-github"
aria-label="GitHub"
color="neutral"
variant="ghost"
/>
</template>
</UHeader>
<UMain class="px-6">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UMain>
<UMain>
<NuxtPage />
</UMain>
<USeparator icon="i-simple-icons-nuxtdotjs" />
<USeparator icon="i-simple-icons-nuxtdotjs" />
<UFooter>
<template #left>
<p class="text-sm text-muted">Built with Nuxt UI © {{ new Date().getFullYear() }}</p>
</template>
<UFooter>
<template #left>
<p class="text-sm text-muted">
Built with Nuxt UI © {{ new Date().getFullYear() }}
</p>
</template>
<template #right>
<UButton
to="https://github.com/nuxt-ui-templates/starter"
target="_blank"
icon="i-simple-icons-github"
aria-label="GitHub"
color="neutral"
variant="ghost"
/>
</template>
</UFooter>
<template #right>
<UButton
to="https://github.com/nuxt-ui-templates/starter"
target="_blank"
icon="i-simple-icons-github"
aria-label="GitHub"
color="neutral"
variant="ghost" />
</template>
</UFooter>
</div>
</UApp>
</template>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
import { PROJECT_STATUSES, type ProjectStatus } from "@workspace/schema/constants";
const props = withDefaults(
defineProps<{
name?: string;
description?: string;
status?: ProjectStatus;
saving?: boolean;
}>(),
{
name: "",
description: "",
status: "active",
},
);
const emit = defineEmits<{
submit: [body: { name: string; description?: string; status: string }];
}>();
const name = ref(props.name);
const description = ref(props.description);
const status = ref(props.status);
function onSubmit() {
emit("submit", {
name: name.value,
description: description.value || undefined,
status: status.value,
});
}
</script>
<template>
<form class="space-y-6" @submit.prevent="onSubmit">
<UFormField label="Name" required>
<UInput v-model="name" placeholder="Project name" class="w-full" required />
Review

Explore use of NuxtUI's UForm

Explore use of NuxtUI's UForm
</UFormField>
<UFormField label="Description">
<UTextarea v-model="description" placeholder="Optional description" class="w-full" :rows="3" />
</UFormField>
<UFormField label="Status">
<USelect v-model="status" :items="PROJECT_STATUSES.map((s) => ({ label: s, value: s }))" class="w-full" />
</UFormField>
<div class="flex gap-3 justify-end">
<UButton label="Cancel" color="neutral" variant="outline" to="/projects" />
<UButton type="submit" label="Save" color="primary" :loading="saving" />
</div>
</form>
</template>

View file

@ -2,48 +2,55 @@
<UDropdownMenu
v-slot="{ open }"
:modal="false"
:items="[{
label: 'Starter',
to: 'https://starter-template.nuxt.dev/',
color: 'primary',
checked: true,
type: 'checkbox',
}, {
label: 'Landing',
to: 'https://landing-template.nuxt.dev/',
}, {
label: 'Docs',
to: 'https://docs-template.nuxt.dev/',
}, {
label: 'SaaS',
to: 'https://saas-template.nuxt.dev/',
}, {
label: 'Dashboard',
to: 'https://dashboard-template.nuxt.dev/',
}, {
label: 'Chat',
to: 'https://chat-template.nuxt.dev/',
}, {
label: 'Portfolio',
to: 'https://portfolio-template.nuxt.dev/',
}, {
label: 'Changelog',
to: 'https://changelog-template.nuxt.dev/',
}]"
:items="[
{
label: 'Starter',
to: 'https://starter-template.nuxt.dev/',
color: 'primary',
checked: true,
type: 'checkbox',
},
{
label: 'Landing',
to: 'https://landing-template.nuxt.dev/',
},
{
label: 'Docs',
to: 'https://docs-template.nuxt.dev/',
},
{
label: 'SaaS',
to: 'https://saas-template.nuxt.dev/',
},
{
label: 'Dashboard',
to: 'https://dashboard-template.nuxt.dev/',
},
{
label: 'Chat',
to: 'https://chat-template.nuxt.dev/',
},
{
label: 'Portfolio',
to: 'https://portfolio-template.nuxt.dev/',
},
{
label: 'Changelog',
to: 'https://changelog-template.nuxt.dev/',
},
]"
:content="{ align: 'start' }"
:ui="{ content: 'min-w-fit' }"
size="xs"
>
size="xs">
<UButton
label="Starter"
variant="subtle"
trailing-icon="i-lucide-chevron-down"
size="xs"
class="-mb-[6px] font-semibold rounded-full truncate"
class="-mb-1.5 font-semibold rounded-full truncate"
:class="[open && 'bg-primary/15']"
:ui="{
trailingIcon: ['transition-transform duration-200', open ? 'rotate-180' : undefined].filter(Boolean).join(' '),
}"
/>
}" />
</UDropdownMenu>
</template>

View file

@ -1,4 +1,4 @@
import type { Project } from "@workspace/schema";
import type { Project, ProjectInsertDTO, ProjectUpdateDTO } from "@workspace/schema";
interface ApiResponse<T> {
data: T | null;
@ -47,7 +47,7 @@ export function useProjectApi() {
loading.value = false;
}
async function createProject(body: Record<string, unknown>): Promise<Project | null> {
async function createProject(body: Partial<ProjectInsertDTO>): Promise<Project | null> {
loading.value = true;
error.value = null;
@ -66,7 +66,7 @@ export function useProjectApi() {
return data;
}
async function updateProject(id: string, body: Record<string, unknown>): Promise<Project | null> {
async function updateProject(id: string, body: Partial<ProjectUpdateDTO>): Promise<Project | null> {
loading.value = true;
error.value = null;
@ -76,7 +76,7 @@ export function useProjectApi() {
});
if (data) {
const idx = projects.value.findIndex((p) => p.id === id);
const idx = projects.value.findIndex(p => p.id === id);
if (idx !== NOT_FOUND) projects.value[idx] = data;
} else {
error.value = err;
@ -95,7 +95,7 @@ export function useProjectApi() {
if (err) {
error.value = err;
} else {
projects.value = projects.value.filter((p) => p.id !== id);
projects.value = projects.value.filter(p => p.id !== id);
}
loading.value = false;

View file

@ -0,0 +1,3 @@
<template>
<slot />
</template>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import type { Project } from "@workspace/schema";
definePageMeta({
title: "Edit Project",
});
const route = useRoute();
const router = useRouter();
const { updateProject } = useProjectApi();
const saving = ref(false);
const project = ref<Project | null>(null);
const loading = ref(true);
const notFound = ref(false);
const config = useRuntimeConfig();
const apiBase = config.public.apiBaseUrl as string;
onMounted(async () => {
const res = await fetch(`${apiBase}/projects/${route.params.id}`);
if (!res.ok) {
notFound.value = true;
} else {
project.value = await res.json();
}
loading.value = false;
});
async function handleSubmit(body: { name: string; description?: string; status: string }) {
saving.value = true;
const updated = await updateProject(route.params.id as string, body);
saving.value = false;
if (updated) router.push("/projects");
}
</script>
<template>
<UPage>
<UPageHeader title="Edit Project" description="Update project details">
Review

Not working

Not working
<template #right>
<UButton label="Back" icon="i-lucide-chevron-left" color="neutral" variant="outline" to="/projects" />
</template>
</UPageHeader>
<UPageBody>
<UAlert v-if="notFound" color="warning" variant="soft" icon="i-lucide-alert-triangle" title="Project not found" />
<UProgress v-else-if="loading" />
<UCard v-else-if="project">
Review

Explore suspense for this async case

Explore suspense for this async case
<ProjectForm
:name="project.name"
:description="project.description ?? undefined"
:status="project.status"
:saving
@submit="handleSubmit"
/>
</UCard>
</UPageBody>
</UPage>
</template>

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
import type { TableColumn } from "@nuxt/ui";
import type { Project } from "@workspace/schema";
definePageMeta({
title: "Projects",
});
const { projects, loading, error, fetchProjects, deleteProject } = useProjectApi();
const deleting = ref<string | null>(null);
const confirmDelete = ref(false);
onMounted(() => fetchProjects());
function statusColor(status: string) {
if (status === "active") return "success" as const;
if (status === "archived") return "neutral" as const;
if (status === "completed") return "info" as const;
return "neutral" as const;
}
async function confirmAndDelete(id: string) {
deleting.value = id;
await deleteProject(id);
deleting.value = null;
confirmDelete.value = false;
}
const columns: TableColumn<Project>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "status", header: "Status" },
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => row.getValue("description") ?? "—",
},
{ accessorKey: "createdAt", header: "Created" },
{ id: "actions", header: "" },
];
</script>
<template>
<UPage>
<UPageHeader
title="Projects"
description="Manage your projects"
:links="[{ label: 'New project', icon: 'i-lucide-plus', to: '/projects/new', color: 'primary' }]" />
<UPageBody>
<UAlert v-if="error" color="error" variant="soft" icon="i-lucide-alert-circle" :title="error" class="mb-4" />
<UCard>
<UTable :columns="columns" :data="projects" :loading="loading" class="flex-1">
<template #status-cell="{ row }">
<UBadge
:label="row.getValue('status')"
:color="statusColor(row.getValue('status') as string)"
variant="subtle"
class="capitalize" />
</template>
<template #createdAt-cell="{ row }">
<span class="text-muted">{{ new Date(row.getValue("createdAt") as string).toLocaleDateString() }}</span>
</template>
<template #actions-cell="{ row }">
<div class="flex justify-end gap-1">
<UButton
icon="i-lucide-pencil"
color="neutral"
variant="ghost"
size="sm"
:to="`/projects/${row.original.id}/edit`"
aria-label="Edit" />
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="sm"
:loading="deleting === row.original.id"
aria-label="Delete"
@click="
confirmDelete = true;
deleting = row.original.id;
" />
</div>
</template>
<template #empty>
<div class="py-12 text-center text-muted">
<UIcon name="i-lucide-folder-open" class="size-8 mx-auto mb-2" />
<p>No projects yet.</p>
</div>
</template>
</UTable>
</UCard>
<UModal v-model:open="confirmDelete">
<template #header>
<h3 class="text-lg font-semibold">Delete project</h3>
</template>
<template #body>
<p class="text-sm text-muted">Are you sure you want to delete this project? This action cannot be undone.</p>
</template>
<template #footer>
<div class="flex gap-3 justify-end">
<UButton label="Cancel" color="neutral" variant="outline" @click="confirmDelete = false" />
<UButton label="Delete" color="error" @click="deleting && confirmAndDelete(deleting)" />
</div>
</template>
</UModal>
</UPageBody>
</UPage>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
definePageMeta({
title: "New Project",
});
const router = useRouter();
const { createProject } = useProjectApi();
const saving = ref(false);
async function handleSubmit(body: { name: string; description?: string; status: string }) {
saving.value = true;
const project = await createProject(body);
saving.value = false;
if (project) router.push("/projects");
}
</script>
<template>
<UPage>
<UPageHeader
title="New Project"
description="Create a new project"
/>
<UPageBody>
<UCard>
<ProjectForm
:saving
@submit="handleSubmit"
/>
</UCard>
</UPageBody>
</UPage>
</template>

View file

@ -6,5 +6,9 @@ export default withNuxt({
rules: {
...baseRules,
"@stylistic/member-delimiter-style": "off",
"vue/max-attributes-per-line": "off",
"vue/singleline-html-element-content-newline": "off",
"vue/html-closing-bracket-newline": "off",
"vue/no-multiple-template-root": "off",
},
},)

View file

@ -10,6 +10,16 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./constants": {
"types": "./src/constants.ts",
"import": "./src/constants.ts",
"default": "./src/constants.ts"
},
"./migrations": {
"types": "./dist/migrations.d.ts",
"import": "./dist/migrations.js",
"default": "./dist/migrations.js"
}
},
"scripts": {

View file

@ -1,11 +1,10 @@
import { defineConfig } from "rolldown";
export default defineConfig({
input: "src/index.ts",
input: ["src/index.ts", "src/migrations.ts"],
output: {
format: "esm",
dir: "dist",
entryFileNames: "index.js",
},
external: (id: string) =>
id === "drizzle-orm" ||

View file

@ -0,0 +1,8 @@
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
export type Priority = (typeof PRIORITIES)[number];

View file

@ -2,9 +2,7 @@ import { pgTable, uuid, text, timestamp, check } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
import { InferOutput, picklist, pipe } from "valibot";
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
import { PROJECT_STATUSES } from "../constants";
const projectStatusValues = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
const createProjectStatusSchema = (schema: any) => pipe(schema, picklist([...PROJECT_STATUSES])) as any;

View file

@ -3,12 +3,7 @@ import { sql } from "drizzle-orm";
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
import { InferOutput, picklist, pipe } from "valibot";
import { projects } from "./projects";
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
export type Priority = (typeof PRIORITIES)[number];
import { TASK_STATUSES, PRIORITIES } from "../constants";
const taskStatusValues = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
const priorityValues = PRIORITIES.map((p) => `'${p}'`).join(", ");

View file

@ -1,34 +1,30 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const migrationsDir = resolve(__dirname, "../drizzle");
export {
PROJECT_STATUSES,
type ProjectStatus,
TASK_STATUSES,
type TaskStatus,
PRIORITIES,
type Priority,
} from "./constants";
export { eq } from "drizzle-orm";
export { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres";
export { migrate } from "drizzle-orm/node-postgres/migrator";
export {
PROJECT_STATUSES,
projects,
projectSelect,
projectInsert,
projectUpdate,
type Project,
type ProjectStatus,
type ProjectInsertDTO,
type ProjectUpdateDTO,
} from "./drizzle/projects";
export { projectsRelations, tasksRelations } from "./drizzle/relations";
export {
TASK_STATUSES,
PRIORITIES,
tasks,
taskSelect,
taskInsert,
taskUpdate,
type Task,
type TaskStatus,
type Priority,
} from "./drizzle/tasks";

View file

@ -0,0 +1,6 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const migrationsDir = resolve(__dirname, "../drizzle");