diff --git a/client/src/branded.ts b/client/src/branded.ts deleted file mode 100644 index 91bb85a..0000000 --- a/client/src/branded.ts +++ /dev/null @@ -1 +0,0 @@ -export type ProjectId = string & { __brand: "ProjectId" }; diff --git a/client/src/organization.ts b/client/src/organization.ts index 06e91de..f6d4345 100644 --- a/client/src/organization.ts +++ b/client/src/organization.ts @@ -44,11 +44,9 @@ export async function createOrganization(userId: UserId, name: string) { const response = await fetch(`/api/users/${userId}/organizations`, { method: "POST", headers: { - "Content-Type": "application/json", + "Content-Type": "text/plain", }, - body: JSON.stringify({ - name, - }), + body: name, }); if (!response.ok) { @@ -62,7 +60,7 @@ export async function updateOrganizationName(userId: UserId, id: Id, name: strin const response = await fetch(`/api/users/${userId}/organizations/${id}/name`, { method: "PUT", headers: { - "Content-Type": "application/json", + "Content-Type": "text/plain", }, body: name, }); @@ -73,3 +71,7 @@ export async function updateOrganizationName(userId: UserId, id: Id, name: strin throw new Error("Failed to update organization name. See console for more details."); } } + +export const isOrganization = ( + organization: Organization | OptimisticOrganization, +): organization is Organization => "id" in organization; diff --git a/client/src/project.ts b/client/src/project.ts index b1b0f72..40c5fcf 100644 --- a/client/src/project.ts +++ b/client/src/project.ts @@ -1,10 +1,12 @@ import { queryOptions, useQueryClient } from "@tanstack/solid-query"; import { createResource } from "solid-js"; -import type { ProjectId } from "./branded"; +import { type Organization, type Id as OrganizationId } from "./organization"; import { idQuery, QUERY_BASE } from "./user"; -export type Project = { id: ProjectId; name: string }; +export type Id = string & { __brand: "ProjectId" }; +export type Project = { id: Id; name: string; organization: Organization }; +export type OptimisticProject = Omit; export function useProjects() { const queryClient = useQueryClient(); @@ -25,12 +27,13 @@ export function useProjects() { return projects; } -export const query = (userId: string | undefined) => +export const query = (userId: string | undefined, signal?: AbortSignal) => queryOptions({ queryKey: [QUERY_BASE, userId, "projects"], - async queryFn() { + async queryFn(): Promise<(Project | OptimisticProject)[]> { const response = await fetch(`/api/users/${userId}/projects`, { method: "GET", + signal, }); if (!response.ok) @@ -41,3 +44,35 @@ export const query = (userId: string | undefined) => }, enabled: Boolean(userId), }); + +export type UpdateProjectParameters = { + userId: string; + id: Id; + name: string; + organizationId: OrganizationId; +}; + +export async function updateProject({ userId, id, ...parameters }: UpdateProjectParameters) { + const response = await fetch(`/api/users/${userId}/projects/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(parameters), + }); + + if (!response.ok) + throw new Error(`Error updating project: ${response.status} ${await response.text()}`); +} + +export const isProject = (project: Project | OptimisticProject): project is Project => + "id" in project; + +export async function deleteProject(id: Id) { + const response = await fetch(`/api/projects/${id}`, { + method: "DELETE", + }); + + if (!response.ok) + throw new Error(`Failed to delete project. See network response for more details.`); +} diff --git a/client/src/routes/_app/organizations/index.tsx b/client/src/routes/_app/organizations/index.tsx index 451f671..a1f2502 100644 --- a/client/src/routes/_app/organizations/index.tsx +++ b/client/src/routes/_app/organizations/index.tsx @@ -5,9 +5,8 @@ import { For, Show } from "solid-js"; import Icon from "../../../Icon"; import { deleteOrganization, + isOrganization, query, - type OptimisticOrganization, - type Organization, type Id as OrganizationId, } from "../../../organization"; import { Title } from "../../../Title"; @@ -49,10 +48,6 @@ function Organizations() { const isLastOrganization = () => organizationsQuery.status === "success" && organizationsQuery.data?.length === 1; - const isOrganization = ( - organization: Organization | OptimisticOrganization, - ): organization is Organization => "id" in organization; - return ( <> @@ -86,6 +81,7 @@ function Organizations() { <Link to="/organizations/$id/edit" params={{ id: id() }} + search={{ name: organization.name }} data-variant="standard" class="icon-button" > diff --git a/client/src/routes/_app/projects.tsx b/client/src/routes/_app/projects.tsx index 16c4bce..b2e4076 100644 --- a/client/src/routes/_app/projects.tsx +++ b/client/src/routes/_app/projects.tsx @@ -1,12 +1,20 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"; import { createFileRoute, Link } from "@tanstack/solid-router"; -import { For, type VoidProps } from "solid-js"; +import { For, Show, type VoidProps } from "solid-js"; import Icon from "../../Icon"; -import { useProjects, type Project } from "../../project"; +import { deleteProject, isProject, query, type Id as ProjectId, type Project } from "../../project"; import { Title } from "../../Title"; +import { idQuery } from "../../user"; export const Route = createFileRoute("/_app/projects")({ component: Projects, + async loader({ context: { queryClient }, abortController }) { + const userId = await queryClient.ensureQueryData(idQuery); + const projects = await queryClient.ensureQueryData(query(userId, abortController.signal)); + + return { projects, userId }; + }, }); function Card(properties: VoidProps<{ project: Project }>) { @@ -14,23 +22,78 @@ function Card(properties: VoidProps<{ project: Project }>) { } function Projects() { - const projects = useProjects(); + const routeData = Route.useLoaderData(); + const userId = () => routeData().userId; + + const options = () => query(userId()); + const projectsQuery = useQuery(options); + const queryClient = useQueryClient(); + + const deleteMutation = useMutation(() => { + const currentUserId = userId(); + const queryKey = ["user", currentUserId, "projects"]; + + return { + mutationFn: deleteProject, + async onSettled() { + await queryClient.invalidateQueries({ queryKey }); + }, + }; + }); + + const isDeleting = (id: ProjectId) => deleteMutation.isPending && deleteMutation.variables === id; + + const isLastProject = () => + projectsQuery.status === "success" && projectsQuery.data?.length === 1; return ( <> + <Title title="Projects" /> <Link to="/projects/new" class="floating-action-button bottom-22"> <span class="sr-only">Create project</span> <Icon name="add" class="fill-on-primary size-6" /> </Link> - <Title title="Projects" /> - <main> - <main class="text-title-lg px-6"> - <ul class="flex flex-col gap-4"> - <For each={projects()} fallback={<p>Loading projects...</p>}> - {(project) => <Card project={project} />} - </For> - </ul> - </main> + {/* TODO overflow, pagination, scrolling */} + <main class="text-title-lg px-6"> + <ul class="grid grid-cols-[1fr_auto_auto_auto] gap-y-4"> + <For each={projectsQuery.data} fallback={<p>Loading projects...</p>}> + {(project) => { + return ( + <Show when={!isProject(project) || !isDeleting(project.id)}> + <li class="bg-surface-container rounded-large col-span-full grid grid-cols-subgrid items-center p-4"> + <span class="ps-3">{project.name}</span> + + <Show when={isProject(project) && project.id}> + {(id) => ( + <> + <Link + to="/projects/$id/edit" + params={{ id: id() }} + search={{ name: project.name, organizationId: project.organization.id }} + data-variant="standard" + class="icon-button" + > + <span class="sr-only">Edit</span> + <Icon name="edit" class="fill-on-surface size-6" /> + </Link> + <button + disabled={isLastProject()} + onClick={() => deleteMutation.mutate(id())} + data-variant="standard" + class="icon-button" + > + <span class="sr-only">Delete</span> + <Icon name="close" class="fill-on-surface size-6" /> + </button> + </> + )} + </Show> + </li> + </Show> + ); + }} + </For> + </ul> </main> </> ); diff --git a/client/src/routes/organizations/$id/edit.tsx b/client/src/routes/organizations/$id/edit.tsx index bc4689f..445aa55 100644 --- a/client/src/routes/organizations/$id/edit.tsx +++ b/client/src/routes/organizations/$id/edit.tsx @@ -5,7 +5,6 @@ import Body from "../../../Body"; import Icon from "../../../Icon"; import { query, updateOrganizationName, type Id as OrganizationId } from "../../../organization"; import { idQuery, type Id as UserId } from "../../../user"; -import { Route as OrganizationsRoute } from "../../_app/organizations"; export const Route = createFileRoute("/organizations/$id/edit")({ component: RouteComponent, @@ -43,11 +42,11 @@ function RouteComponent() { // Optimistically update to the new value context.client.setQueryData(queryOptions.queryKey, (old) => { - const org = old?.find( + const organization = old?.find( (organization) => "id" in organization && organization.id === variables.id, ); - if (org) { - org.name = variables.name; + if (organization) { + organization.name = variables.name; } return old; }); diff --git a/client/src/routes/projects/$id.edit.tsx b/client/src/routes/projects/$id.edit.tsx new file mode 100644 index 0000000..e0eff14 --- /dev/null +++ b/client/src/routes/projects/$id.edit.tsx @@ -0,0 +1,153 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/solid-router"; +import { For, Suspense } from "solid-js"; + +import Body from "../../Body"; +import Icon from "../../Icon"; +import { + query as organizationQuery, + isOrganization, + type Id as OrganizationId, +} from "../../organization"; +import { query, updateProject, type Id as ProjectId } from "../../project"; +import { idQuery } from "../../user"; + +export const Route = createFileRoute("/projects/$id/edit")({ + component: RouteComponent, + async loader({ context }) { + const userId = await context.queryClient.ensureQueryData(idQuery); + return { userId }; + }, + validateSearch(search) { + if ( + search.name && + typeof search.name === "string" && + search.organizationId && + typeof search.organizationId === "string" + ) + return { name: search.name, organizationId: search.organizationId }; + + return {}; + }, +}); + +function RouteComponent() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const search = Route.useSearch(); + const loaderData = Route.useLoaderData(); + const parameters = Route.useParams(); + const userIdQuery = useQuery(() => idQuery); + const organizations = useQuery(() => organizationQuery(userIdQuery.data)); + + const updateNameMutation = useMutation(() => ({ + mutationFn: updateProject, + async onMutate(variables, context) { + const userId = await context.client.fetchQuery(idQuery); + const queryOptions = query(userId); + // Cancel any outgoing refetches + // (so they don't overwrite our optimistic update) + await context.client.cancelQueries(queryOptions); + + // Snapshot the previous value + const previous = context.client.getQueryData(queryOptions.queryKey); + + // Optimistically update to the new value + context.client.setQueryData(queryOptions.queryKey, (old) => { + const project = old?.find((project) => "id" in project && project.id === variables.id); + if (project) { + project.name = variables.name; + } + return old; + }); + + // Return the snapshotted value + return { userId, previous }; + }, + + async onError(_error, variables, onMutateResult, context) { + context.client.setQueryData(query(onMutateResult?.userId).queryKey, onMutateResult?.previous); + await navigate({ + to: "/projects/$id/edit", + params: { id: variables.id }, + search: { name: variables.name, organizationId: variables.organizationId }, + }); + }, + async onSuccess(_data, _error, variables) { + await queryClient.invalidateQueries({ queryKey: query(variables.userId).queryKey }); + }, + })); + + async function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + + const form = event.currentTarget as HTMLFormElement; + const nameInput = form.elements.namedItem("name") as HTMLInputElement; + const name = nameInput.value; + const userId = loaderData().userId; + const organizationSelect = form.elements.namedItem("organization") as HTMLSelectElement; + const organizationId = organizationSelect.value as OrganizationId; + //TODO find more elegant way to casting to ProjectId + const id = parameters().id as ProjectId; + updateNameMutation.mutate({ userId, id, name, organizationId }); + await navigate({ to: "/projects" }); + } + + const selectableOrganizations = () => organizations.data?.filter(isOrganization); + + return ( + <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> + <header class="bg-surface-container-high text-on-surface flex py-1"> + <Link to="/projects" class="cursor-default p-4"> + <span class="sr-only">Discard</span> + <Icon name="close" class="fill-on-surface size-6" /> + </Link> + <h1 class="text-title-lg content-center">Edit Project</h1> + </header> + <main class="h-min"> + <form id="project" onSubmit={handleSubmit} class="grid h-full grid-cols-2 gap-x-4 p-6"> + {/* TODO same day toggle */} + {/* TODO form validation start < end */} + <label for="name" class="text-label-lg text-on-surface-variant col-span-2 block"> + Name + </label> + <input + type="text" + id="name" + required + value={search().name ?? ""} + class="text-field col-span-2 mt-1 w-full" + /> + <label + for="organization" + class="text-label-lg text-on-surface-variant row-start-3 mt-4 block" + > + Organization + </label> + <select id="organization" required class="text-field col-span-2 mt-1 w-full"> + <Suspense + fallback={ + <option value="" disabled> + Loading... + </option> + } + > + <For each={selectableOrganizations()}> + {(organization) => <option value={organization.id}>{organization.name}</option>} + </For> + </Suspense> + </select> + </form> + </main> + <footer class="mt-6 grid grid-cols-2 gap-4 px-6 py-4"> + <Link to="/projects" data-variant="outlined" class="button"> + Cancel + </Link> + + <button type="submit" form="project" data-variant="filled" class="button"> + Save + </button> + </footer> + </Body> + ); +} diff --git a/client/src/routes/projects/new.tsx b/client/src/routes/projects/new.tsx index ab2f3f0..4b70318 100644 --- a/client/src/routes/projects/new.tsx +++ b/client/src/routes/projects/new.tsx @@ -4,7 +4,7 @@ import { For, Suspense } from "solid-js"; import Body from "../../Body"; import Icon from "../../Icon"; -import { query } from "../../organization"; +import { isOrganization, query } from "../../organization"; import { idQuery } from "../../user"; export const Route = createFileRoute("/projects/new")({ @@ -48,6 +48,8 @@ function RouteComponent() { navigate({ to: "/projects" }); } + const selectableOrganizations = () => organizations.data?.filter(isOrganization); + return ( <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> @@ -80,7 +82,7 @@ function RouteComponent() { </option> } > - <For each={organizations.data}> + <For each={selectableOrganizations()}> {(organization) => <option value={organization.id}>{organization.name}</option>} </For> </Suspense> diff --git a/server/src/main/java/com/yealch/yealch/Project.java b/server/src/main/java/com/yealch/yealch/Project.java index 7352178..8e8ee68 100644 --- a/server/src/main/java/com/yealch/yealch/Project.java +++ b/server/src/main/java/com/yealch/yealch/Project.java @@ -51,4 +51,16 @@ public class Project { public Organization getOrganization() { return organization; } + + public Long getOrganizationId() { + return organizationId; + } + + public Organization getOrganizationEntity() { + return organization; + } + + public void setOrganizationId(Long organizationId) { + this.organizationId = organizationId; + } } diff --git a/server/src/main/java/com/yealch/yealch/ProjectsController.java b/server/src/main/java/com/yealch/yealch/ProjectsController.java new file mode 100644 index 0000000..7efcc2a --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/ProjectsController.java @@ -0,0 +1,23 @@ +package com.yealch.yealch; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/organizations") +public class ProjectsController { + + private final ProjectRepository projectRepository; + + public ProjectsController(ProjectRepository projectRepository) { + this.projectRepository = projectRepository; + } + + @DeleteMapping("/{projectId}") + public void deleteProject(@PathVariable Long projectId) { + // TODO validate user is authorized to delete project + projectRepository.deleteById(projectId); + } +} diff --git a/server/src/main/java/com/yealch/yealch/UsersController.java b/server/src/main/java/com/yealch/yealch/UsersController.java index 4dede7c..56e8adc 100644 --- a/server/src/main/java/com/yealch/yealch/UsersController.java +++ b/server/src/main/java/com/yealch/yealch/UsersController.java @@ -286,11 +286,10 @@ public class UsersController { /** Creates a new organization with this user in it */ @PostMapping("/api/users/{userId}/organizations") public ResponseEntity<?> createOrganizationForUser(@PathVariable Long userId, - @RequestBody Map<String, String> request) { + @RequestBody String organizationName) { // TODO check on authorization if the user id is the same user that sends the // request - String organizationName = request.get("name"); if (organizationName == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "organization name is required")); } @@ -332,4 +331,36 @@ public class UsersController { .body(Map.of("error", "organization not found")))) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); } + + record UpdateProjectRequest(String name, Long organizationId) { + } + + @PutMapping("/api/users/{userId}/projects/{projectId}") + public ResponseEntity<?> updateProject(@PathVariable Long userId, @PathVariable Long projectId, + @RequestBody UpdateProjectRequest request) { + + // + if (request == null || request.name() == null || request.organizationId() == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "name and organizationId are required")); + } + + return userRepository.findById(userId) + .<ResponseEntity<?>>map(user -> projectRepository.findById(projectId) + .<ResponseEntity<?>>map(project -> { + if (!project.getOrganization().getMembers().contains(user)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "user is not a member of the project")); + } + + project.setName(request.name()); + project.setOrganizationId(request.organizationId()); + projectRepository.save(project); + + return ResponseEntity.ok().build(); + }) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "project not found")))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + } }