mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
edit + delete capabilities for org, project, sprint
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Issue, Organisation, Project, User } from "@sprint/shared";
|
import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ export async function updateProject(
|
|||||||
export async function deleteProject(projectId: number) {
|
export async function deleteProject(projectId: number) {
|
||||||
// delete all of the project's issues first
|
// delete all of the project's issues first
|
||||||
await db.delete(Issue).where(eq(Issue.projectId, projectId));
|
await db.delete(Issue).where(eq(Issue.projectId, projectId));
|
||||||
|
// delete all of the project's sprints
|
||||||
|
await db.delete(Sprint).where(eq(Sprint.projectId, projectId));
|
||||||
// delete actual project
|
// delete actual project
|
||||||
await db.delete(Project).where(eq(Project.id, projectId));
|
await db.delete(Project).where(eq(Project.id, projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Sprint } from "@sprint/shared";
|
import { Issue, Sprint } from "@sprint/shared";
|
||||||
import { and, eq, gte, lte } from "drizzle-orm";
|
import { and, desc, eq, gte, lte, ne } from "drizzle-orm";
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
|
|
||||||
export async function createSprint(
|
export async function createSprint(
|
||||||
@@ -22,21 +22,55 @@ export async function createSprint(
|
|||||||
return sprint;
|
return sprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSprintsByProject(projectId: number) {
|
export async function getSprintById(sprintId: number) {
|
||||||
return await db.select().from(Sprint).where(eq(Sprint.projectId, projectId));
|
const [sprint] = await db.select().from(Sprint).where(eq(Sprint.id, sprintId));
|
||||||
|
return sprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasOverlappingSprints(projectId: number, startDate: Date, endDate: Date) {
|
export async function getSprintsByProject(projectId: number) {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(Sprint)
|
||||||
|
.where(eq(Sprint.projectId, projectId))
|
||||||
|
.orderBy(desc(Sprint.startDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasOverlappingSprints(
|
||||||
|
projectId: number,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
excludeSprintId?: number,
|
||||||
|
) {
|
||||||
|
const conditions = [
|
||||||
|
eq(Sprint.projectId, projectId),
|
||||||
|
lte(Sprint.startDate, endDate),
|
||||||
|
gte(Sprint.endDate, startDate),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (excludeSprintId !== undefined) {
|
||||||
|
console.log("Excluding sprint ID:", excludeSprintId);
|
||||||
|
conditions.push(ne(Sprint.id, excludeSprintId));
|
||||||
|
}
|
||||||
|
|
||||||
const overlapping = await db
|
const overlapping = await db
|
||||||
.select({ id: Sprint.id })
|
.select({ id: Sprint.id })
|
||||||
.from(Sprint)
|
.from(Sprint)
|
||||||
.where(
|
.where(and(...conditions))
|
||||||
and(
|
|
||||||
eq(Sprint.projectId, projectId),
|
|
||||||
lte(Sprint.startDate, endDate),
|
|
||||||
gte(Sprint.endDate, startDate),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
console.log(overlapping);
|
||||||
return overlapping.length > 0;
|
return overlapping.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSprint(
|
||||||
|
sprintId: number,
|
||||||
|
updates: { name?: string; color?: string; startDate?: Date; endDate?: Date },
|
||||||
|
) {
|
||||||
|
const [sprint] = await db.update(Sprint).set(updates).where(eq(Sprint.id, sprintId)).returning();
|
||||||
|
return sprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSprint(sprintId: number) {
|
||||||
|
await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId));
|
||||||
|
await db.delete(Sprint).where(eq(Sprint.id, sprintId));
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ const main = async () => {
|
|||||||
"/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)),
|
"/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)),
|
||||||
|
|
||||||
"/sprint/create": withCors(withAuth(withCSRF(routes.sprintCreate))),
|
"/sprint/create": withCors(withAuth(withCSRF(routes.sprintCreate))),
|
||||||
|
"/sprint/update": withCors(withAuth(withCSRF(routes.sprintUpdate))),
|
||||||
|
"/sprint/delete": withCors(withAuth(withCSRF(routes.sprintDelete))),
|
||||||
"/sprints/by-project": withCors(withAuth(routes.sprintsByProject)),
|
"/sprints/by-project": withCors(withAuth(routes.sprintsByProject)),
|
||||||
|
|
||||||
"/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))),
|
"/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))),
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import projectUpdate from "./project/update";
|
|||||||
import projectWithCreator from "./project/with-creator";
|
import projectWithCreator from "./project/with-creator";
|
||||||
import projectsWithCreators from "./project/with-creators";
|
import projectsWithCreators from "./project/with-creators";
|
||||||
import sprintCreate from "./sprint/create";
|
import sprintCreate from "./sprint/create";
|
||||||
|
import sprintDelete from "./sprint/delete";
|
||||||
|
import sprintUpdate from "./sprint/update";
|
||||||
import sprintsByProject from "./sprints/by-project";
|
import sprintsByProject from "./sprints/by-project";
|
||||||
import timerEnd from "./timer/end";
|
import timerEnd from "./timer/end";
|
||||||
import timerGet from "./timer/get";
|
import timerGet from "./timer/get";
|
||||||
@@ -78,6 +80,8 @@ export const routes = {
|
|||||||
projectsWithCreators,
|
projectsWithCreators,
|
||||||
|
|
||||||
sprintCreate,
|
sprintCreate,
|
||||||
|
sprintUpdate,
|
||||||
|
sprintDelete,
|
||||||
sprintsByProject,
|
sprintsByProject,
|
||||||
|
|
||||||
timerToggle,
|
timerToggle,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { OrgDeleteRequestSchema } from "@sprint/shared";
|
import { OrgDeleteRequestSchema } from "@sprint/shared";
|
||||||
import type { BunRequest } from "bun";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { deleteOrganisation, getOrganisationById } from "../../db/queries";
|
import { deleteOrganisation, getOrganisationById, getOrganisationMemberRole } from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function organisationDelete(req: BunRequest) {
|
export default async function organisationDelete(req: AuthedRequest) {
|
||||||
const parsed = await parseJsonBody(req, OrgDeleteRequestSchema);
|
const parsed = await parseJsonBody(req, OrgDeleteRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
@@ -14,6 +14,14 @@ export default async function organisationDelete(req: BunRequest) {
|
|||||||
return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
|
return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requesterMember = await getOrganisationMemberRole(id, req.userId);
|
||||||
|
if (!requesterMember) {
|
||||||
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
if (requesterMember.role !== "owner") {
|
||||||
|
return errorResponse("only owners can delete organisations", "PERMISSION_DENIED", 403);
|
||||||
|
}
|
||||||
|
|
||||||
await deleteOrganisation(id);
|
await deleteOrganisation(id);
|
||||||
|
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { OrgUpdateRequestSchema } from "@sprint/shared";
|
import { OrgUpdateRequestSchema } from "@sprint/shared";
|
||||||
import type { BunRequest } from "bun";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { getOrganisationById, updateOrganisation } from "../../db/queries";
|
import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function organisationUpdate(req: BunRequest) {
|
export default async function organisationUpdate(req: AuthedRequest) {
|
||||||
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
|
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
@@ -14,6 +14,14 @@ export default async function organisationUpdate(req: BunRequest) {
|
|||||||
return errorResponse(`organisation with id ${id} does not exist`, "ORG_NOT_FOUND", 404);
|
return errorResponse(`organisation with id ${id} does not exist`, "ORG_NOT_FOUND", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requesterMember = await getOrganisationMemberRole(id, req.userId);
|
||||||
|
if (!requesterMember) {
|
||||||
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
|
||||||
|
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (!name && !description && !slug && !statuses) {
|
if (!name && !description && !slug && !statuses) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"at least one of name, description, slug, or statuses must be provided",
|
"at least one of name, description, slug, or statuses must be provided",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ProjectDeleteRequestSchema } from "@sprint/shared";
|
import { ProjectDeleteRequestSchema } from "@sprint/shared";
|
||||||
import type { BunRequest } from "bun";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { deleteProject, getProjectByID } from "../../db/queries";
|
import { deleteProject, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function projectDelete(req: BunRequest) {
|
export default async function projectDelete(req: AuthedRequest) {
|
||||||
const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema);
|
const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
@@ -14,6 +14,22 @@ export default async function projectDelete(req: BunRequest) {
|
|||||||
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
|
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requesterMember = await getOrganisationMemberRole(existingProject.organisationId, req.userId);
|
||||||
|
if (!requesterMember) {
|
||||||
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrgOwner = requesterMember.role === "owner";
|
||||||
|
const isProjectCreator = existingProject.creatorId === req.userId;
|
||||||
|
|
||||||
|
if (!isOrgOwner && !isProjectCreator) {
|
||||||
|
return errorResponse(
|
||||||
|
"only organisation owners or the project creator can delete projects",
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await deleteProject(id);
|
await deleteProject(id);
|
||||||
|
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { ProjectUpdateRequestSchema } from "@sprint/shared";
|
import { ProjectUpdateRequestSchema } from "@sprint/shared";
|
||||||
import type { BunRequest } from "bun";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { getProjectByID, getProjectByKey, getUserById, updateProject } from "../../db/queries";
|
import {
|
||||||
|
getOrganisationMemberRole,
|
||||||
|
getProjectByID,
|
||||||
|
getProjectByKey,
|
||||||
|
getUserById,
|
||||||
|
updateProject,
|
||||||
|
} from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function projectUpdate(req: BunRequest) {
|
export default async function projectUpdate(req: AuthedRequest) {
|
||||||
const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema);
|
const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
@@ -14,6 +20,18 @@ export default async function projectUpdate(req: BunRequest) {
|
|||||||
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
|
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requesterMember = await getOrganisationMemberRole(existingProject.organisationId, req.userId);
|
||||||
|
if (!requesterMember) {
|
||||||
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
|
||||||
|
return errorResponse(
|
||||||
|
"only organisation owners and admins can edit projects",
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!key && !name && !creatorId && !organisationId) {
|
if (!key && !name && !creatorId && !organisationId) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"at least one of key, name, creatorId, or organisationId must be provided",
|
"at least one of key, name, creatorId, or organisationId must be provided",
|
||||||
|
|||||||
42
packages/backend/src/routes/sprint/delete.ts
Normal file
42
packages/backend/src/routes/sprint/delete.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { SprintDeleteRequestSchema } from "@sprint/shared";
|
||||||
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
|
import { deleteSprint, getOrganisationMemberRole, getProjectByID, getSprintById } from "../../db/queries";
|
||||||
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
|
export default async function sprintDelete(req: AuthedRequest) {
|
||||||
|
const parsed = await parseJsonBody(req, SprintDeleteRequestSchema);
|
||||||
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
|
const { id } = parsed.data;
|
||||||
|
|
||||||
|
const existingSprint = await getSprintById(id);
|
||||||
|
if (!existingSprint) {
|
||||||
|
return errorResponse(`sprint with id ${id} does not exist`, "SPRINT_NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await getProjectByID(existingSprint.projectId);
|
||||||
|
if (!project) {
|
||||||
|
return errorResponse("project not found", "PROJECT_NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId);
|
||||||
|
if (!requesterMember) {
|
||||||
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrgOwner = requesterMember.role === "owner";
|
||||||
|
const isAdmin = requesterMember.role === "admin";
|
||||||
|
const isProjectCreator = project.creatorId === req.userId;
|
||||||
|
|
||||||
|
if (!isOrgOwner && !isAdmin && !isProjectCreator) {
|
||||||
|
return errorResponse(
|
||||||
|
"only organisation owners, admins, or project creators can delete sprints",
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteSprint(id);
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
76
packages/backend/src/routes/sprint/update.ts
Normal file
76
packages/backend/src/routes/sprint/update.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { SprintUpdateRequestSchema } from "@sprint/shared";
|
||||||
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
|
import {
|
||||||
|
getOrganisationMemberRole,
|
||||||
|
getProjectByID,
|
||||||
|
getSprintById,
|
||||||
|
hasOverlappingSprints,
|
||||||
|
updateSprint,
|
||||||
|
} from "../../db/queries";
|
||||||
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
|
export default async function sprintUpdate(req: AuthedRequest) {
|
||||||
|
const parsed = await parseJsonBody(req, SprintUpdateRequestSchema);
|
||||||
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
|
const { id, name, color, startDate, endDate } = parsed.data;
|
||||||
|
|
||||||
|
const existingSprint = await getSprintById(id);
|
||||||
|
if (!existingSprint) {
|
||||||
|
return errorResponse(`sprint with id ${id} does not exist`, "SPRINT_NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await getProjectByID(existingSprint.projectId);
|
||||||
|
if (!project) {
|
||||||
|
return errorResponse("project not found", "PROJECT_NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId);
|
||||||
|
if (!requesterMember) {
|
||||||
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
|
||||||
|
return errorResponse(
|
||||||
|
"only organisation owners and admins can edit sprints",
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name && !color && !startDate && !endDate) {
|
||||||
|
return errorResponse(
|
||||||
|
"at least one of name, color, startDate, or endDate must be provided",
|
||||||
|
"NO_UPDATES",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate dates if provided
|
||||||
|
const newStartDate = startDate ? new Date(startDate) : existingSprint.startDate;
|
||||||
|
const newEndDate = endDate ? new Date(endDate) : existingSprint.endDate;
|
||||||
|
|
||||||
|
if (newStartDate > newEndDate) {
|
||||||
|
return errorResponse("End date must be after start date", "INVALID_DATES", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
const hasOverlap = await hasOverlappingSprints(
|
||||||
|
project.id,
|
||||||
|
newStartDate,
|
||||||
|
newEndDate,
|
||||||
|
existingSprint.id,
|
||||||
|
);
|
||||||
|
if (hasOverlap) {
|
||||||
|
return errorResponse("Sprint dates overlap with an existing sprint", "SPRINT_OVERLAP", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sprint = await updateSprint(id, {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
startDate: startDate ? newStartDate : undefined,
|
||||||
|
endDate: endDate ? newEndDate : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(sprint);
|
||||||
|
}
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import {
|
|
||||||
ORG_DESCRIPTION_MAX_LENGTH,
|
|
||||||
ORG_NAME_MAX_LENGTH,
|
|
||||||
ORG_SLUG_MAX_LENGTH,
|
|
||||||
type OrganisationRecord,
|
|
||||||
} from "@sprint/shared";
|
|
||||||
import { type FormEvent, useState } from "react";
|
|
||||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Field } from "@/components/ui/field";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { organisation, parseError } from "@/lib/server";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const slugify = (value: string) =>
|
|
||||||
value
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+/, "")
|
|
||||||
.replace(/-{2,}/g, "-");
|
|
||||||
|
|
||||||
export function CreateOrganisation({
|
|
||||||
trigger,
|
|
||||||
completeAction,
|
|
||||||
errorAction,
|
|
||||||
}: {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
|
|
||||||
errorAction?: (errorMessage: string) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const { user } = useAuthenticatedSession();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [slug, setSlug] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
|
||||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setName("");
|
|
||||||
setSlug("");
|
|
||||||
setDescription("");
|
|
||||||
setSlugManuallyEdited(false);
|
|
||||||
setSubmitAttempted(false);
|
|
||||||
setSubmitting(false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenChange = (nextOpen: boolean) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSubmitAttempted(true);
|
|
||||||
|
|
||||||
if (name.trim() === "" || name.trim().length > ORG_NAME_MAX_LENGTH) return;
|
|
||||||
if (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
|
|
||||||
if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
|
|
||||||
|
|
||||||
if (!user.id) {
|
|
||||||
setError("you must be logged in to create an organisation");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await organisation.create({
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
description,
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
setOpen(false);
|
|
||||||
reset();
|
|
||||||
try {
|
|
||||||
await completeAction?.(data);
|
|
||||||
} catch (actionErr) {
|
|
||||||
console.error(actionErr);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: async (err) => {
|
|
||||||
const message = parseError(err);
|
|
||||||
setError(message || "failed to create organisation");
|
|
||||||
setSubmitting(false);
|
|
||||||
try {
|
|
||||||
await errorAction?.(message || "failed to create organisation");
|
|
||||||
} catch (actionErr) {
|
|
||||||
console.error(actionErr);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError("failed to create organisation");
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger || <Button variant="outline">Create Organisation</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Organisation</DialogTitle>
|
|
||||||
{/* <DialogDescription>Enter the details for the new organisation.</DialogDescription> */}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid mt-2">
|
|
||||||
<Field
|
|
||||||
label="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextName = e.target.value;
|
|
||||||
setName(nextName);
|
|
||||||
if (!slugManuallyEdited) {
|
|
||||||
setSlug(slugify(nextName));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
validate={(v) => {
|
|
||||||
if (v.trim() === "") return "Cannot be empty";
|
|
||||||
if (v.trim().length > ORG_NAME_MAX_LENGTH) {
|
|
||||||
return `Too long (${ORG_NAME_MAX_LENGTH} character limit)`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
placeholder="Demo Organisation"
|
|
||||||
maxLength={ORG_NAME_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Slug"
|
|
||||||
value={slug}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSlug(slugify(e.target.value));
|
|
||||||
setSlugManuallyEdited(true);
|
|
||||||
}}
|
|
||||||
validate={(v) => {
|
|
||||||
if (v.trim() === "") return "Cannot be empty";
|
|
||||||
if (v.trim().length > ORG_SLUG_MAX_LENGTH) {
|
|
||||||
return `Too long (${ORG_SLUG_MAX_LENGTH} character limit)`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
placeholder="demo-organisation"
|
|
||||||
maxLength={ORG_SLUG_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Description (optional)"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
validate={(v) => {
|
|
||||||
if (v.trim().length > ORG_DESCRIPTION_MAX_LENGTH) {
|
|
||||||
return `Too long (${ORG_DESCRIPTION_MAX_LENGTH} character limit)`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
placeholder="What is this organisation for?"
|
|
||||||
maxLength={ORG_DESCRIPTION_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
|
||||||
{error ? (
|
|
||||||
<Label className="text-destructive text-sm">{error}</Label>
|
|
||||||
) : (
|
|
||||||
<Label className="opacity-0 text-sm">a</Label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 w-full justify-end mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
name.trim() === "" ||
|
|
||||||
name.trim().length > ORG_NAME_MAX_LENGTH ||
|
|
||||||
slug.trim() === "" ||
|
|
||||||
slug.trim().length > ORG_SLUG_MAX_LENGTH ||
|
|
||||||
description.trim().length > ORG_DESCRIPTION_MAX_LENGTH
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{submitting ? "Creating..." : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* <DialogFooter> */}
|
|
||||||
{/* </DialogFooter> */}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
import { PROJECT_NAME_MAX_LENGTH, type ProjectRecord } from "@sprint/shared";
|
|
||||||
import { type FormEvent, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Field } from "@/components/ui/field";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { parseError, project } from "@/lib/server";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const keyify = (value: string) =>
|
|
||||||
value
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/[^A-Z0-9]/g, "")
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
export function CreateProject({
|
|
||||||
organisationId,
|
|
||||||
trigger,
|
|
||||||
completeAction,
|
|
||||||
}: {
|
|
||||||
organisationId?: number;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
completeAction?: (project: ProjectRecord) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const { user } = useAuthenticatedSession();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
|
|
||||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setName("");
|
|
||||||
setKey("");
|
|
||||||
setKeyManuallyEdited(false);
|
|
||||||
setSubmitAttempted(false);
|
|
||||||
setSubmitting(false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenChange = (nextOpen: boolean) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSubmitAttempted(true);
|
|
||||||
|
|
||||||
if (
|
|
||||||
name.trim() === "" ||
|
|
||||||
name.trim().length > PROJECT_NAME_MAX_LENGTH ||
|
|
||||||
key.trim() === "" ||
|
|
||||||
key.length > 4
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.id) {
|
|
||||||
setError("you must be logged in to create a project");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organisationId) {
|
|
||||||
setError("select an organisation first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await project.create({
|
|
||||||
key,
|
|
||||||
name,
|
|
||||||
organisationId,
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
const proj = data as ProjectRecord;
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
reset();
|
|
||||||
try {
|
|
||||||
await completeAction?.(proj);
|
|
||||||
} catch (actionErr) {
|
|
||||||
console.error(actionErr);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
const message = parseError(err);
|
|
||||||
setError(message);
|
|
||||||
setSubmitting(false);
|
|
||||||
|
|
||||||
toast.error(`Error creating project: ${message}`, {
|
|
||||||
dismissible: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError("failed to create project");
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger || (
|
|
||||||
<Button variant="outline" disabled={!organisationId}>
|
|
||||||
Create Project
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Project</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid mt-2">
|
|
||||||
<Field
|
|
||||||
label="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextName = e.target.value;
|
|
||||||
setName(nextName);
|
|
||||||
if (!keyManuallyEdited) {
|
|
||||||
setKey(keyify(nextName));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
validate={(v) => {
|
|
||||||
if (v.trim() === "") return "Cannot be empty";
|
|
||||||
if (v.trim().length > PROJECT_NAME_MAX_LENGTH) {
|
|
||||||
return `Too long (${PROJECT_NAME_MAX_LENGTH} character limit)`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
placeholder="Demo Project"
|
|
||||||
maxLength={PROJECT_NAME_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Key"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => {
|
|
||||||
setKey(keyify(e.target.value));
|
|
||||||
setKeyManuallyEdited(true);
|
|
||||||
}}
|
|
||||||
validate={(v) => {
|
|
||||||
if (v.trim() === "") return "Cannot be empty";
|
|
||||||
if (v.length > 4) return "Must be 4 or less characters";
|
|
||||||
return undefined;
|
|
||||||
}}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
placeholder="DEMO"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
|
||||||
{error ? (
|
|
||||||
<Label className="text-destructive text-sm">{error}</Label>
|
|
||||||
) : (
|
|
||||||
<Label className="opacity-0 text-sm">a</Label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 w-full justify-end mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
(name.trim() === "" && submitAttempted) ||
|
|
||||||
(name.trim().length > PROJECT_NAME_MAX_LENGTH && submitAttempted) ||
|
|
||||||
((key.trim() === "" || key.length > 4) && submitAttempted)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{submitting ? "Creating..." : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
|
|
||||||
import { type FormEvent, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import ColourPicker from "@/components/ui/colour-picker";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Field } from "@/components/ui/field";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { parseError, sprint } from "@/lib/server";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const SPRINT_NAME_MAX_LENGTH = 64;
|
|
||||||
|
|
||||||
const getStartOfDay = (date: Date) => {
|
|
||||||
const next = new Date(date);
|
|
||||||
next.setHours(0, 0, 0, 0);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEndOfDay = (date: Date) => {
|
|
||||||
const next = new Date(date);
|
|
||||||
next.setHours(23, 59, 0, 0);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
|
||||||
const next = new Date(date);
|
|
||||||
next.setDate(next.getDate() + days);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultDates = () => {
|
|
||||||
const today = new Date();
|
|
||||||
return {
|
|
||||||
start: getStartOfDay(today),
|
|
||||||
end: getEndOfDay(addDays(today, 14)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreateSprint({
|
|
||||||
projectId,
|
|
||||||
sprints,
|
|
||||||
trigger,
|
|
||||||
completeAction,
|
|
||||||
}: {
|
|
||||||
projectId?: number;
|
|
||||||
sprints: SprintRecord[];
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
completeAction?: (sprint: SprintRecord) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const { user } = useAuthenticatedSession();
|
|
||||||
|
|
||||||
const { start, end } = getDefaultDates();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
|
|
||||||
const [startDate, setStartDate] = useState(start);
|
|
||||||
const [endDate, setEndDate] = useState(end);
|
|
||||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const dateError = useMemo(() => {
|
|
||||||
if (!submitAttempted) return "";
|
|
||||||
if (startDate > endDate) {
|
|
||||||
return "End date must be after start date";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}, [endDate, startDate, submitAttempted]);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
const defaults = getDefaultDates();
|
|
||||||
setName("");
|
|
||||||
setColour(DEFAULT_SPRINT_COLOUR);
|
|
||||||
setStartDate(defaults.start);
|
|
||||||
setEndDate(defaults.end);
|
|
||||||
setSubmitAttempted(false);
|
|
||||||
setSubmitting(false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenChange = (nextOpen: boolean) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSubmitAttempted(true);
|
|
||||||
|
|
||||||
if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate > endDate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.id) {
|
|
||||||
setError("you must be logged in to create a sprint");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!projectId) {
|
|
||||||
setError("select a project first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sprint.create({
|
|
||||||
projectId,
|
|
||||||
name,
|
|
||||||
color: colour, // hm - always unsure which i should use
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
setOpen(false);
|
|
||||||
reset();
|
|
||||||
try {
|
|
||||||
await completeAction?.(data);
|
|
||||||
} catch (actionErr) {
|
|
||||||
console.error(actionErr);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
const message = parseError(err);
|
|
||||||
setError(message);
|
|
||||||
setSubmitting(false);
|
|
||||||
|
|
||||||
toast.error(`Error creating sprint: ${message}`, {
|
|
||||||
dismissible: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (submitError) {
|
|
||||||
console.error(submitError);
|
|
||||||
setError("failed to create sprint");
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger || (
|
|
||||||
<Button variant="outline" disabled={!projectId}>
|
|
||||||
Create Sprint
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className={cn("w-md", (error || dateError) && "border-destructive")}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Sprint</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Field
|
|
||||||
label="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
validate={(value) =>
|
|
||||||
value.trim() === ""
|
|
||||||
? "Cannot be empty"
|
|
||||||
: value.trim().length > SPRINT_NAME_MAX_LENGTH
|
|
||||||
? `Too long (${SPRINT_NAME_MAX_LENGTH} character limit)`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
placeholder="Sprint 1"
|
|
||||||
maxLength={SPRINT_NAME_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Start Date</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="justify-start">
|
|
||||||
{startDate.toLocaleDateString()}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="center">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={startDate}
|
|
||||||
onSelect={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
setStartDate(getStartOfDay(value));
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
sprints={sprints}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>End Date</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="justify-start">
|
|
||||||
{endDate.toLocaleDateString()}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="center">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={endDate}
|
|
||||||
onSelect={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
setEndDate(getEndOfDay(value));
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
sprints={sprints}
|
|
||||||
isEnd
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label>Colour</Label>
|
|
||||||
<ColourPicker colour={colour} onChange={setColour} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
|
||||||
{error || dateError ? (
|
|
||||||
<Label className="text-destructive text-sm">{error ?? dateError}</Label>
|
|
||||||
) : (
|
|
||||||
<Label className="opacity-0 text-sm">a</Label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 w-full justify-end mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) &&
|
|
||||||
submitAttempted) ||
|
|
||||||
(dateError !== "" && submitAttempted)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{submitting ? "Creating..." : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ import { issue, parseError } from "@/lib/server";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SprintSelect } from "./sprint-select";
|
import { SprintSelect } from "./sprint-select";
|
||||||
|
|
||||||
export function CreateIssue({
|
export function IssueModal({
|
||||||
projectId,
|
projectId,
|
||||||
sprints,
|
sprints,
|
||||||
members,
|
members,
|
||||||
279
packages/frontend/src/components/organisation-modal.tsx
Normal file
279
packages/frontend/src/components/organisation-modal.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
ORG_DESCRIPTION_MAX_LENGTH,
|
||||||
|
ORG_NAME_MAX_LENGTH,
|
||||||
|
ORG_SLUG_MAX_LENGTH,
|
||||||
|
type OrganisationRecord,
|
||||||
|
} from "@sprint/shared";
|
||||||
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field } from "@/components/ui/field";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { organisation, parseError } from "@/lib/server";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const slugify = (value: string) =>
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-{2,}/g, "-");
|
||||||
|
|
||||||
|
export function OrganisationModal({
|
||||||
|
trigger,
|
||||||
|
completeAction,
|
||||||
|
errorAction,
|
||||||
|
mode = "create",
|
||||||
|
existingOrganisation,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
}: {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
|
||||||
|
errorAction?: (errorMessage: string) => void | Promise<void>;
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
existingOrganisation?: OrganisationRecord;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuthenticatedSession();
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && existingOrganisation && open) {
|
||||||
|
setName(existingOrganisation.name);
|
||||||
|
setSlug(existingOrganisation.slug);
|
||||||
|
setDescription(existingOrganisation.description ?? "");
|
||||||
|
setSlugManuallyEdited(true);
|
||||||
|
}
|
||||||
|
}, [isEdit, existingOrganisation, open]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setName("");
|
||||||
|
setSlug("");
|
||||||
|
setDescription("");
|
||||||
|
setSlugManuallyEdited(false);
|
||||||
|
setSubmitAttempted(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitAttempted(true);
|
||||||
|
|
||||||
|
if (name.trim() === "" || name.trim().length > ORG_NAME_MAX_LENGTH) return;
|
||||||
|
if (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
|
||||||
|
if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
|
||||||
|
|
||||||
|
if (!user.id) {
|
||||||
|
setError(`you must be logged in to ${isEdit ? "edit" : "create"} an organisation`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (isEdit && existingOrganisation) {
|
||||||
|
await organisation.update({
|
||||||
|
organisationId: existingOrganisation.id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
toast.success("Organisation updated");
|
||||||
|
try {
|
||||||
|
await completeAction?.(data);
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: async (err) => {
|
||||||
|
const message = parseError(err);
|
||||||
|
setError(message || "failed to update organisation");
|
||||||
|
setSubmitting(false);
|
||||||
|
try {
|
||||||
|
await errorAction?.(message || "failed to update organisation");
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await organisation.create({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
try {
|
||||||
|
await completeAction?.(data);
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: async (err) => {
|
||||||
|
const message = parseError(err);
|
||||||
|
setError(message || "failed to create organisation");
|
||||||
|
setSubmitting(false);
|
||||||
|
try {
|
||||||
|
await errorAction?.(message || "failed to create organisation");
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(`failed to ${isEdit ? "update" : "create"} organisation`);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogContent = (
|
||||||
|
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit Organisation" : "Create Organisation"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid mt-2">
|
||||||
|
<Field
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextName = e.target.value;
|
||||||
|
setName(nextName);
|
||||||
|
if (!slugManuallyEdited) {
|
||||||
|
setSlug(slugify(nextName));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
validate={(v) => {
|
||||||
|
if (v.trim() === "") return "Cannot be empty";
|
||||||
|
if (v.trim().length > ORG_NAME_MAX_LENGTH) {
|
||||||
|
return `Too long (${ORG_NAME_MAX_LENGTH} character limit)`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="Demo Organisation"
|
||||||
|
maxLength={ORG_NAME_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlug(slugify(e.target.value));
|
||||||
|
setSlugManuallyEdited(true);
|
||||||
|
}}
|
||||||
|
validate={(v) => {
|
||||||
|
if (v.trim() === "") return "Cannot be empty";
|
||||||
|
if (v.trim().length > ORG_SLUG_MAX_LENGTH) {
|
||||||
|
return `Too long (${ORG_SLUG_MAX_LENGTH} character limit)`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="demo-organisation"
|
||||||
|
maxLength={ORG_SLUG_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
validate={(v) => {
|
||||||
|
if (v.trim().length > ORG_DESCRIPTION_MAX_LENGTH) {
|
||||||
|
return `Too long (${ORG_DESCRIPTION_MAX_LENGTH} character limit)`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="What is this organisation for?"
|
||||||
|
maxLength={ORG_DESCRIPTION_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||||
|
{error ? (
|
||||||
|
<Label className="text-destructive text-sm">{error}</Label>
|
||||||
|
) : (
|
||||||
|
<Label className="opacity-0 text-sm">a</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 w-full justify-end mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
name.trim() === "" ||
|
||||||
|
name.trim().length > ORG_NAME_MAX_LENGTH ||
|
||||||
|
slug.trim() === "" ||
|
||||||
|
slug.trim().length > ORG_SLUG_MAX_LENGTH ||
|
||||||
|
description.trim().length > ORG_DESCRIPTION_MAX_LENGTH
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isControlled) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || <Button variant="outline">Create Organisation</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared";
|
import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CreateOrganisation } from "@/components/create-organisation";
|
import { OrganisationModal } from "@/components/organisation-modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -74,7 +74,7 @@ export function OrganisationSelect({
|
|||||||
{organisations.length > 0 && <SelectSeparator />}
|
{organisations.length > 0 && <SelectSeparator />}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
|
|
||||||
<CreateOrganisation
|
<OrganisationModal
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" className={"w-full"} size={"sm"}>
|
<Button variant="ghost" className={"w-full"} size={"sm"}>
|
||||||
Create Organisation
|
Create Organisation
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddMemberDialog } from "@/components/add-member-dialog";
|
import { AddMemberDialog } from "@/components/add-member-dialog";
|
||||||
import { CreateSprint } from "@/components/create-sprint";
|
import { OrganisationModal } from "@/components/organisation-modal";
|
||||||
import { OrganisationSelect } from "@/components/organisation-select";
|
import { OrganisationSelect } from "@/components/organisation-select";
|
||||||
|
import { ProjectModal } from "@/components/project-modal";
|
||||||
import { ProjectSelect } from "@/components/project-select";
|
import { ProjectSelect } from "@/components/project-select";
|
||||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
import SmallSprintDisplay from "@/components/small-sprint-display";
|
import SmallSprintDisplay from "@/components/small-sprint-display";
|
||||||
import SmallUserDisplay from "@/components/small-user-display";
|
import SmallUserDisplay from "@/components/small-user-display";
|
||||||
|
import { SprintModal } from "@/components/sprint-modal";
|
||||||
import StatusTag from "@/components/status-tag";
|
import StatusTag from "@/components/status-tag";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import ColourPicker from "@/components/ui/colour-picker";
|
import ColourPicker from "@/components/ui/colour-picker";
|
||||||
@@ -32,7 +34,7 @@ import { IconButton } from "@/components/ui/icon-button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { issue, organisation } from "@/lib/server";
|
import { issue, organisation, project, sprint } from "@/lib/server";
|
||||||
import { capitalise } from "@/lib/utils";
|
import { capitalise } from "@/lib/utils";
|
||||||
|
|
||||||
function OrganisationsDialog({
|
function OrganisationsDialog({
|
||||||
@@ -75,6 +77,12 @@ function OrganisationsDialog({
|
|||||||
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
|
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
|
||||||
const [reassignToStatus, setReassignToStatus] = useState<string>("");
|
const [reassignToStatus, setReassignToStatus] = useState<string>("");
|
||||||
|
|
||||||
|
// edit/delete state for organisations, projects, and sprints
|
||||||
|
const [editOrgOpen, setEditOrgOpen] = useState(false);
|
||||||
|
const [editProjectOpen, setEditProjectOpen] = useState(false);
|
||||||
|
const [editSprintOpen, setEditSprintOpen] = useState(false);
|
||||||
|
const [editingSprint, setEditingSprint] = useState<SprintRecord | null>(null);
|
||||||
|
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -97,6 +105,10 @@ function OrganisationsDialog({
|
|||||||
selectedOrganisation?.OrganisationMember.role === "owner" ||
|
selectedOrganisation?.OrganisationMember.role === "owner" ||
|
||||||
selectedOrganisation?.OrganisationMember.role === "admin";
|
selectedOrganisation?.OrganisationMember.role === "admin";
|
||||||
|
|
||||||
|
const isOwner = selectedOrganisation?.OrganisationMember.role === "owner";
|
||||||
|
|
||||||
|
const canDeleteProject = isOwner || (selectedProject && selectedProject.Project.creatorId === user?.id);
|
||||||
|
|
||||||
const formatDate = (value: Date | string) =>
|
const formatDate = (value: Date | string) =>
|
||||||
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
const getSprintDateRange = (sprint: SprintRecord) => {
|
const getSprintDateRange = (sprint: SprintRecord) => {
|
||||||
@@ -527,7 +539,67 @@ function OrganisationsDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditOrgOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon icon="edit" className="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{isOwner && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmDialog({
|
||||||
|
open: true,
|
||||||
|
title: "Delete Organisation",
|
||||||
|
message: `Are you sure you want to delete "${selectedOrganisation.Organisation.name}"? This action cannot be undone and will delete all projects, sprints, and issues.`,
|
||||||
|
confirmText: "Delete",
|
||||||
|
processingText: "Deleting...",
|
||||||
|
variant: "destructive",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await organisation.remove({
|
||||||
|
organisationId:
|
||||||
|
selectedOrganisation.Organisation.id,
|
||||||
|
onSuccess: async () => {
|
||||||
|
closeConfirmDialog();
|
||||||
|
toast.success(
|
||||||
|
`Deleted organisation "${selectedOrganisation.Organisation.name}"`,
|
||||||
|
);
|
||||||
|
setSelectedOrganisation(null);
|
||||||
|
await refetchOrganisations();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="trash" className="size-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OrganisationModal
|
||||||
|
mode="edit"
|
||||||
|
existingOrganisation={selectedOrganisation.Organisation}
|
||||||
|
open={editOrgOpen}
|
||||||
|
onOpenChange={setEditOrgOpen}
|
||||||
|
completeAction={async () => {
|
||||||
|
await refetchOrganisations({
|
||||||
|
selectOrganisationId: selectedOrganisation.Organisation.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="users">
|
<TabsContent value="users">
|
||||||
@@ -650,6 +722,63 @@ function OrganisationsDialog({
|
|||||||
Creator: {selectedProject.User.name}
|
Creator: {selectedProject.User.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditProjectOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon icon="edit" className="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{canDeleteProject && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmDialog({
|
||||||
|
open: true,
|
||||||
|
title: "Delete Project",
|
||||||
|
message: `Are you sure you want to delete "${selectedProject.Project.name}"? This will delete all sprints and issues in this project.`,
|
||||||
|
confirmText: "Delete",
|
||||||
|
processingText: "Deleting...",
|
||||||
|
variant: "destructive",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await project.remove({
|
||||||
|
projectId:
|
||||||
|
selectedProject
|
||||||
|
.Project.id,
|
||||||
|
onSuccess:
|
||||||
|
async () => {
|
||||||
|
closeConfirmDialog();
|
||||||
|
toast.success(
|
||||||
|
`Deleted project "${selectedProject.Project.name}"`,
|
||||||
|
);
|
||||||
|
onSelectedProjectChange(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await refetchOrganisations();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error(
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="trash"
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -660,30 +789,118 @@ function OrganisationsDialog({
|
|||||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||||
{selectedProject ? (
|
{selectedProject ? (
|
||||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
||||||
{sprints.map((sprint) => {
|
{sprints.map((sprintItem) => {
|
||||||
const dateRange = getSprintDateRange(sprint);
|
const dateRange = getSprintDateRange(sprintItem);
|
||||||
const isCurrent = isCurrentSprint(sprint);
|
const isCurrent = isCurrentSprint(sprintItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sprint.id}
|
key={sprintItem.id}
|
||||||
className={`flex items-center justify-between p-2 border ${
|
className={`flex items-center justify-between p-2 border ${
|
||||||
isCurrent
|
isCurrent
|
||||||
? "border-emerald-500/60 bg-emerald-500/10"
|
? "border-emerald-500/60 bg-emerald-500/10"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SmallSprintDisplay sprint={sprint} />
|
<SmallSprintDisplay sprint={sprintItem} />
|
||||||
{dateRange && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
{dateRange && (
|
||||||
{dateRange}
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
{dateRange}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
size={"sm"}
|
||||||
|
noStyle
|
||||||
|
className="hover:opacity-80 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ellipsisVertical"
|
||||||
|
className="size-4 text-foreground"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setEditingSprint(
|
||||||
|
sprintItem,
|
||||||
|
);
|
||||||
|
setEditSprintOpen(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="hover:bg-primary-foreground"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="edit"
|
||||||
|
className="size-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => {
|
||||||
|
setConfirmDialog({
|
||||||
|
open: true,
|
||||||
|
title: "Delete Sprint",
|
||||||
|
message: `Are you sure you want to delete "${sprintItem.name}"? Issues assigned to this sprint will become unassigned.`,
|
||||||
|
confirmText:
|
||||||
|
"Delete",
|
||||||
|
processingText:
|
||||||
|
"Deleting...",
|
||||||
|
variant:
|
||||||
|
"destructive",
|
||||||
|
onConfirm:
|
||||||
|
async () => {
|
||||||
|
await sprint.remove(
|
||||||
|
{
|
||||||
|
sprintId:
|
||||||
|
sprintItem.id,
|
||||||
|
onSuccess:
|
||||||
|
async () => {
|
||||||
|
closeConfirmDialog();
|
||||||
|
toast.success(
|
||||||
|
`Deleted sprint "${sprintItem.name}"`,
|
||||||
|
);
|
||||||
|
await refetchOrganisations();
|
||||||
|
},
|
||||||
|
onError:
|
||||||
|
(
|
||||||
|
error,
|
||||||
|
) => {
|
||||||
|
console.error(
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="trash"
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<CreateSprint
|
<SprintModal
|
||||||
projectId={selectedProject?.Project.id}
|
projectId={selectedProject?.Project.id}
|
||||||
completeAction={onCreateSprint}
|
completeAction={onCreateSprint}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -708,6 +925,33 @@ function OrganisationsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedProject && (
|
||||||
|
<>
|
||||||
|
<ProjectModal
|
||||||
|
mode="edit"
|
||||||
|
existingProject={selectedProject.Project}
|
||||||
|
open={editProjectOpen}
|
||||||
|
onOpenChange={setEditProjectOpen}
|
||||||
|
completeAction={async () => {
|
||||||
|
await refetchOrganisations();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SprintModal
|
||||||
|
mode="edit"
|
||||||
|
existingSprint={editingSprint ?? undefined}
|
||||||
|
sprints={sprints}
|
||||||
|
open={editSprintOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setEditSprintOpen(open);
|
||||||
|
if (!open) setEditingSprint(null);
|
||||||
|
}}
|
||||||
|
completeAction={async () => {
|
||||||
|
await refetchOrganisations();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="issues">
|
<TabsContent value="issues">
|
||||||
|
|||||||
268
packages/frontend/src/components/project-modal.tsx
Normal file
268
packages/frontend/src/components/project-modal.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { PROJECT_NAME_MAX_LENGTH, type ProjectRecord } from "@sprint/shared";
|
||||||
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field } from "@/components/ui/field";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { parseError, project } from "@/lib/server";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const keyify = (value: string) =>
|
||||||
|
value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]/g, "")
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
export function ProjectModal({
|
||||||
|
organisationId,
|
||||||
|
trigger,
|
||||||
|
completeAction,
|
||||||
|
mode = "create",
|
||||||
|
existingProject,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
}: {
|
||||||
|
organisationId?: number;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
completeAction?: (project: ProjectRecord) => void | Promise<void>;
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
existingProject?: ProjectRecord;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuthenticatedSession();
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && existingProject && open) {
|
||||||
|
setName(existingProject.name);
|
||||||
|
setKey(existingProject.key);
|
||||||
|
setKeyManuallyEdited(true);
|
||||||
|
}
|
||||||
|
}, [isEdit, existingProject, open]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setName("");
|
||||||
|
setKey("");
|
||||||
|
setKeyManuallyEdited(false);
|
||||||
|
setSubmitAttempted(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitAttempted(true);
|
||||||
|
|
||||||
|
if (
|
||||||
|
name.trim() === "" ||
|
||||||
|
name.trim().length > PROJECT_NAME_MAX_LENGTH ||
|
||||||
|
key.trim() === "" ||
|
||||||
|
key.length > 4
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.id) {
|
||||||
|
setError(`you must be logged in to ${isEdit ? "edit" : "create"} a project`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEdit && !organisationId) {
|
||||||
|
setError("select an organisation first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (isEdit && existingProject) {
|
||||||
|
await project.update({
|
||||||
|
projectId: existingProject.id,
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
const proj = data as ProjectRecord;
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
toast.success("Project updated");
|
||||||
|
try {
|
||||||
|
await completeAction?.(proj);
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = parseError(err);
|
||||||
|
setError(message);
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
toast.error(`Error updating project: ${message}`, {
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!organisationId) {
|
||||||
|
setError("select an organisation first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await project.create({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
organisationId: organisationId,
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
const proj = data as ProjectRecord;
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
try {
|
||||||
|
await completeAction?.(proj);
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = parseError(err);
|
||||||
|
setError(message);
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
toast.error(`Error creating project: ${message}`, {
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(`failed to ${isEdit ? "update" : "create"} project`);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogContent = (
|
||||||
|
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit Project" : "Create Project"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid mt-2">
|
||||||
|
<Field
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextName = e.target.value;
|
||||||
|
setName(nextName);
|
||||||
|
if (!keyManuallyEdited) {
|
||||||
|
setKey(keyify(nextName));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
validate={(v) => {
|
||||||
|
if (v.trim() === "") return "Cannot be empty";
|
||||||
|
if (v.trim().length > PROJECT_NAME_MAX_LENGTH) {
|
||||||
|
return `Too long (${PROJECT_NAME_MAX_LENGTH} character limit)`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="Demo Project"
|
||||||
|
maxLength={PROJECT_NAME_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Key"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKey(keyify(e.target.value));
|
||||||
|
setKeyManuallyEdited(true);
|
||||||
|
}}
|
||||||
|
validate={(v) => {
|
||||||
|
if (v.trim() === "") return "Cannot be empty";
|
||||||
|
if (v.length > 4) return "Must be 4 or less characters";
|
||||||
|
return undefined;
|
||||||
|
}}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="DEMO"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||||
|
{error ? (
|
||||||
|
<Label className="text-destructive text-sm">{error}</Label>
|
||||||
|
) : (
|
||||||
|
<Label className="opacity-0 text-sm">a</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 w-full justify-end mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
(name.trim() === "" && submitAttempted) ||
|
||||||
|
(name.trim().length > PROJECT_NAME_MAX_LENGTH && submitAttempted) ||
|
||||||
|
((key.trim() === "" || key.length > 4) && submitAttempted)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isControlled) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button variant="outline" disabled={!organisationId}>
|
||||||
|
Create Project
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ProjectRecord, ProjectResponse } from "@sprint/shared";
|
import type { ProjectRecord, ProjectResponse } from "@sprint/shared";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CreateProject } from "@/components/create-project";
|
import { ProjectModal } from "@/components/project-modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -68,7 +68,7 @@ export function ProjectSelect({
|
|||||||
))}
|
))}
|
||||||
{projects.length > 0 && <SelectSeparator />}
|
{projects.length > 0 && <SelectSeparator />}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<CreateProject
|
<ProjectModal
|
||||||
organisationId={organisationId}
|
organisationId={organisationId}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!organisationId}>
|
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!organisationId}>
|
||||||
|
|||||||
346
packages/frontend/src/components/sprint-modal.tsx
Normal file
346
packages/frontend/src/components/sprint-modal.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
|
||||||
|
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import ColourPicker from "@/components/ui/colour-picker";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field } from "@/components/ui/field";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { parseError, sprint } from "@/lib/server";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SPRINT_NAME_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
const getStartOfDay = (date: Date) => {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEndOfDay = (date: Date) => {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setHours(23, 59, 0, 0);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDays = (date: Date, days: number) => {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultDates = () => {
|
||||||
|
const today = new Date();
|
||||||
|
return {
|
||||||
|
start: getStartOfDay(today),
|
||||||
|
end: getEndOfDay(addDays(today, 14)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SprintModal({
|
||||||
|
projectId,
|
||||||
|
sprints,
|
||||||
|
trigger,
|
||||||
|
completeAction,
|
||||||
|
mode = "create",
|
||||||
|
existingSprint,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
}: {
|
||||||
|
projectId?: number;
|
||||||
|
sprints: SprintRecord[];
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
completeAction?: (sprint: SprintRecord) => void | Promise<void>;
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
existingSprint?: SprintRecord;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuthenticatedSession();
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
const { start, end } = getDefaultDates();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
|
||||||
|
const [startDate, setStartDate] = useState(start);
|
||||||
|
const [endDate, setEndDate] = useState(end);
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && existingSprint && open) {
|
||||||
|
setName(existingSprint.name);
|
||||||
|
setColour(existingSprint.color);
|
||||||
|
setStartDate(new Date(existingSprint.startDate));
|
||||||
|
setEndDate(new Date(existingSprint.endDate));
|
||||||
|
}
|
||||||
|
}, [isEdit, existingSprint, open]);
|
||||||
|
|
||||||
|
const dateError = useMemo(() => {
|
||||||
|
if (!submitAttempted) return "";
|
||||||
|
if (startDate > endDate) {
|
||||||
|
return "End date must be after start date";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [endDate, startDate, submitAttempted]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
const defaults = getDefaultDates();
|
||||||
|
setName("");
|
||||||
|
setColour(DEFAULT_SPRINT_COLOUR);
|
||||||
|
setStartDate(defaults.start);
|
||||||
|
setEndDate(defaults.end);
|
||||||
|
setSubmitAttempted(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitAttempted(true);
|
||||||
|
|
||||||
|
if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate > endDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.id) {
|
||||||
|
setError(`you must be logged in to ${isEdit ? "edit" : "create"} a sprint`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEdit && !projectId) {
|
||||||
|
setError("select a project first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && existingSprint) {
|
||||||
|
await sprint.update({
|
||||||
|
sprintId: existingSprint.id,
|
||||||
|
name,
|
||||||
|
color: colour,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
toast.success("Sprint updated");
|
||||||
|
try {
|
||||||
|
await completeAction?.(data);
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = parseError(err);
|
||||||
|
setError(message);
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
toast.error(`Error updating sprint: ${message}`, {
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!projectId) {
|
||||||
|
setError("select a project first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sprint.create({
|
||||||
|
projectId: projectId,
|
||||||
|
name,
|
||||||
|
color: colour,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
try {
|
||||||
|
await completeAction?.(data);
|
||||||
|
} catch (actionErr) {
|
||||||
|
console.error(actionErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = parseError(err);
|
||||||
|
setError(message);
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
toast.error(`Error creating sprint: ${message}`, {
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (submitError) {
|
||||||
|
console.error(submitError);
|
||||||
|
setError(`failed to ${isEdit ? "update" : "create"} sprint`);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// filter out current sprint from the calendar display when editing
|
||||||
|
const calendarSprints =
|
||||||
|
isEdit && existingSprint ? sprints.filter((s) => s.id !== existingSprint.id) : sprints;
|
||||||
|
|
||||||
|
const dialogContent = (
|
||||||
|
<DialogContent className={cn("w-md", (error || dateError) && "border-destructive")}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit Sprint" : "Create Sprint"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Field
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
validate={(value) =>
|
||||||
|
value.trim() === ""
|
||||||
|
? "Cannot be empty"
|
||||||
|
: value.trim().length > SPRINT_NAME_MAX_LENGTH
|
||||||
|
? `Too long (${SPRINT_NAME_MAX_LENGTH} character limit)`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="Sprint 1"
|
||||||
|
maxLength={SPRINT_NAME_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Start Date</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="justify-start">
|
||||||
|
{startDate.toLocaleDateString()}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="center">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={startDate}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
setStartDate(getStartOfDay(value));
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
sprints={calendarSprints}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>End Date</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="justify-start">
|
||||||
|
{endDate.toLocaleDateString()}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="center">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={endDate}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
setEndDate(getEndOfDay(value));
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
sprints={calendarSprints}
|
||||||
|
isEnd
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label>Colour</Label>
|
||||||
|
<ColourPicker colour={colour} onChange={setColour} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||||
|
{error || dateError ? (
|
||||||
|
<Label className="text-destructive text-sm">{error ?? dateError}</Label>
|
||||||
|
) : (
|
||||||
|
<Label className="opacity-0 text-sm">a</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 w-full justify-end mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) &&
|
||||||
|
submitAttempted) ||
|
||||||
|
(dateError !== "" && submitAttempted)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isControlled) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button variant="outline" disabled={!projectId}>
|
||||||
|
Create Sprint
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -195,13 +195,13 @@ function CalendarDayButton({
|
|||||||
{
|
{
|
||||||
...style,
|
...style,
|
||||||
"--sprint-color": sprint?.color ? sprint.color : null,
|
"--sprint-color": sprint?.color ? sprint.color : null,
|
||||||
"border-left":
|
borderLeft:
|
||||||
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
|
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
|
||||||
? `1px solid ${sprint?.color}`
|
? `1px solid ${sprint?.color}`
|
||||||
: day.date.getDay() === 0 // sunday (left side)
|
: day.date.getDay() === 0 // sunday (left side)
|
||||||
? `1px dashed ${sprint?.color}`
|
? `1px dashed ${sprint?.color}`
|
||||||
: `0px`,
|
: `0px`,
|
||||||
"border-right":
|
borderRight:
|
||||||
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
|
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
|
||||||
? `1px solid ${sprint?.color}`
|
? `1px solid ${sprint?.color}`
|
||||||
: day.date.getDay() === 6 // saturday (right side)
|
: day.date.getDay() === 6 // saturday (right side)
|
||||||
|
|||||||
37
packages/frontend/src/lib/server/organisation/delete.ts
Normal file
37
packages/frontend/src/lib/server/organisation/delete.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SuccessResponse } from "@sprint/shared";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import type { ServerQueryInput } from "..";
|
||||||
|
|
||||||
|
export async function remove({
|
||||||
|
organisationId,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
organisationId: number;
|
||||||
|
} & ServerQueryInput<SuccessResponse>) {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/organisation/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: organisationId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => res.text());
|
||||||
|
const message =
|
||||||
|
typeof error === "string"
|
||||||
|
? error
|
||||||
|
: error.error || `failed to delete organisation (${res.status})`;
|
||||||
|
toast.error(message);
|
||||||
|
onError?.(error);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
onSuccess?.(data, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { addMember } from "@/lib/server/organisation/addMember";
|
export { addMember } from "@/lib/server/organisation/addMember";
|
||||||
export { byUser } from "@/lib/server/organisation/byUser";
|
export { byUser } from "@/lib/server/organisation/byUser";
|
||||||
export { create } from "@/lib/server/organisation/create";
|
export { create } from "@/lib/server/organisation/create";
|
||||||
|
export { remove } from "@/lib/server/organisation/delete";
|
||||||
export { members } from "@/lib/server/organisation/members";
|
export { members } from "@/lib/server/organisation/members";
|
||||||
export { removeMember } from "@/lib/server/organisation/removeMember";
|
export { removeMember } from "@/lib/server/organisation/removeMember";
|
||||||
export { update } from "@/lib/server/organisation/update";
|
export { update } from "@/lib/server/organisation/update";
|
||||||
|
|||||||
35
packages/frontend/src/lib/server/project/delete.ts
Normal file
35
packages/frontend/src/lib/server/project/delete.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { SuccessResponse } from "@sprint/shared";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import type { ServerQueryInput } from "..";
|
||||||
|
|
||||||
|
export async function remove({
|
||||||
|
projectId,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
} & ServerQueryInput<SuccessResponse>) {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/project/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: projectId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => res.text());
|
||||||
|
const message =
|
||||||
|
typeof error === "string" ? error : error.error || `failed to delete project (${res.status})`;
|
||||||
|
toast.error(message);
|
||||||
|
onError?.(error);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
onSuccess?.(data, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { byOrganisation } from "@/lib/server/project/byOrganisation";
|
export { byOrganisation } from "@/lib/server/project/byOrganisation";
|
||||||
export { create } from "@/lib/server/project/create";
|
export { create } from "@/lib/server/project/create";
|
||||||
|
export { remove } from "@/lib/server/project/delete";
|
||||||
|
export { update } from "@/lib/server/project/update";
|
||||||
|
|||||||
43
packages/frontend/src/lib/server/project/update.ts
Normal file
43
packages/frontend/src/lib/server/project/update.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ProjectRecord } from "@sprint/shared";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import type { ServerQueryInput } from "..";
|
||||||
|
|
||||||
|
export async function update({
|
||||||
|
projectId,
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
} & ServerQueryInput<ProjectRecord>) {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/project/update`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => res.text());
|
||||||
|
const message =
|
||||||
|
typeof error === "string" ? error : error.error || `failed to update project (${res.status})`;
|
||||||
|
toast.error(message);
|
||||||
|
onError?.(error);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
onSuccess?.(data, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/frontend/src/lib/server/sprint/delete.ts
Normal file
35
packages/frontend/src/lib/server/sprint/delete.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { SuccessResponse } from "@sprint/shared";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import type { ServerQueryInput } from "..";
|
||||||
|
|
||||||
|
export async function remove({
|
||||||
|
sprintId,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
sprintId: number;
|
||||||
|
} & ServerQueryInput<SuccessResponse>) {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/sprint/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: sprintId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => res.text());
|
||||||
|
const message =
|
||||||
|
typeof error === "string" ? error : error.error || `failed to delete sprint (${res.status})`;
|
||||||
|
toast.error(message);
|
||||||
|
onError?.(error);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
onSuccess?.(data, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { byProject } from "@/lib/server/sprint/byProject";
|
export { byProject } from "@/lib/server/sprint/byProject";
|
||||||
export { create } from "@/lib/server/sprint/create";
|
export { create } from "@/lib/server/sprint/create";
|
||||||
|
export { remove } from "@/lib/server/sprint/delete";
|
||||||
|
export { update } from "@/lib/server/sprint/update";
|
||||||
|
|||||||
49
packages/frontend/src/lib/server/sprint/update.ts
Normal file
49
packages/frontend/src/lib/server/sprint/update.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SprintRecord } from "@sprint/shared";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import type { ServerQueryInput } from "..";
|
||||||
|
|
||||||
|
export async function update({
|
||||||
|
sprintId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
sprintId: number;
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
} & ServerQueryInput<SprintRecord>) {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/sprint/update`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: sprintId,
|
||||||
|
name: name?.trim(),
|
||||||
|
color,
|
||||||
|
startDate: startDate?.toISOString(),
|
||||||
|
endDate: endDate?.toISOString(),
|
||||||
|
}),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => res.text());
|
||||||
|
const message =
|
||||||
|
typeof error === "string" ? error : error.error || `failed to update sprint (${res.status})`;
|
||||||
|
toast.error(message);
|
||||||
|
onError?.(error);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
onSuccess?.(data, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import type {
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import AccountDialog from "@/components/account-dialog";
|
import AccountDialog from "@/components/account-dialog";
|
||||||
import { CreateIssue } from "@/components/create-issue";
|
|
||||||
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
||||||
|
import { IssueModal } from "@/components/issue-modal";
|
||||||
import { IssuesTable } from "@/components/issues-table";
|
import { IssuesTable } from "@/components/issues-table";
|
||||||
import LogOutButton from "@/components/log-out-button";
|
import LogOutButton from "@/components/log-out-button";
|
||||||
import { OrganisationSelect } from "@/components/organisation-select";
|
import { OrganisationSelect } from "@/components/organisation-select";
|
||||||
@@ -459,7 +459,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedOrganisation && selectedProject && (
|
{selectedOrganisation && selectedProject && (
|
||||||
<CreateIssue
|
<IssueModal
|
||||||
projectId={selectedProject?.Project.id}
|
projectId={selectedProject?.Project.id}
|
||||||
sprints={sprints}
|
sprints={sprints}
|
||||||
members={members}
|
members={members}
|
||||||
|
|||||||
@@ -194,8 +194,9 @@ export const ProjectCreateRequestSchema = z.object({
|
|||||||
name: z.string().min(1, "Name is required").max(PROJECT_NAME_MAX_LENGTH),
|
name: z.string().min(1, "Name is required").max(PROJECT_NAME_MAX_LENGTH),
|
||||||
key: z
|
key: z
|
||||||
.string()
|
.string()
|
||||||
.length(4, "Key must be exactly 4 characters")
|
.min(1, "Key is required")
|
||||||
.regex(/^[A-Z]{4}$/, "Key must be 4 uppercase letters"),
|
.max(4, "Key must be 4 characters or less")
|
||||||
|
.regex(/^[A-Za-z]{1,4}$/, "Key must be only letters A-Z"),
|
||||||
organisationId: z.number().int().positive("organisationId must be a positive integer"),
|
organisationId: z.number().int().positive("organisationId must be a positive integer"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,8 +207,9 @@ export const ProjectUpdateRequestSchema = z.object({
|
|||||||
name: z.string().min(1, "Name must be at least 1 character").max(PROJECT_NAME_MAX_LENGTH).optional(),
|
name: z.string().min(1, "Name must be at least 1 character").max(PROJECT_NAME_MAX_LENGTH).optional(),
|
||||||
key: z
|
key: z
|
||||||
.string()
|
.string()
|
||||||
.length(4, "Key must be exactly 4 characters")
|
.min(1, "Key is required")
|
||||||
.regex(/^[A-Z]{4}$/, "Key must be 4 uppercase letters")
|
.max(4, "Key must be 4 characters or less")
|
||||||
|
.regex(/^[A-Za-z]{1,4}$/, "Key must be only letters A-Z")
|
||||||
.optional(),
|
.optional(),
|
||||||
creatorId: z.number().int().positive().optional(),
|
creatorId: z.number().int().positive().optional(),
|
||||||
organisationId: z.number().int().positive().optional(),
|
organisationId: z.number().int().positive().optional(),
|
||||||
@@ -259,6 +261,38 @@ export const SprintCreateRequestSchema = z
|
|||||||
|
|
||||||
export type SprintCreateRequest = z.infer<typeof SprintCreateRequestSchema>;
|
export type SprintCreateRequest = z.infer<typeof SprintCreateRequestSchema>;
|
||||||
|
|
||||||
|
export const SprintUpdateRequestSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.number().int().positive("id must be a positive integer"),
|
||||||
|
name: z.string().min(1, "Name is required").max(64, "Name must be at most 64 characters").optional(),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9a-fA-F]{6}$/, "Color must be a valid hex color")
|
||||||
|
.optional(),
|
||||||
|
startDate: z.string().datetime("Start date must be a valid date").optional(),
|
||||||
|
endDate: z.string().datetime("End date must be a valid date").optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.startDate && data.endDate) {
|
||||||
|
return new Date(data.startDate) <= new Date(data.endDate);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "End date must be after start date",
|
||||||
|
path: ["endDate"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SprintUpdateRequest = z.infer<typeof SprintUpdateRequestSchema>;
|
||||||
|
|
||||||
|
export const SprintDeleteRequestSchema = z.object({
|
||||||
|
id: z.number().int().positive("id must be a positive integer"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SprintDeleteRequest = z.infer<typeof SprintDeleteRequestSchema>;
|
||||||
|
|
||||||
export const SprintsByProjectQuerySchema = z.object({
|
export const SprintsByProjectQuerySchema = z.object({
|
||||||
projectId: z.coerce.number().int().positive("projectId must be a positive integer"),
|
projectId: z.coerce.number().int().positive("projectId must be a positive integer"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ export type {
|
|||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
ReplaceStatusResponse,
|
ReplaceStatusResponse,
|
||||||
SprintCreateRequest,
|
SprintCreateRequest,
|
||||||
|
SprintDeleteRequest,
|
||||||
SprintResponseType,
|
SprintResponseType,
|
||||||
SprintsByProjectQuery,
|
SprintsByProjectQuery,
|
||||||
|
SprintUpdateRequest,
|
||||||
StatusCountResponse,
|
StatusCountResponse,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
TimerEndRequest,
|
TimerEndRequest,
|
||||||
@@ -75,8 +77,10 @@ export {
|
|||||||
RegisterRequestSchema,
|
RegisterRequestSchema,
|
||||||
ReplaceStatusResponseSchema,
|
ReplaceStatusResponseSchema,
|
||||||
SprintCreateRequestSchema,
|
SprintCreateRequestSchema,
|
||||||
|
SprintDeleteRequestSchema,
|
||||||
SprintRecordSchema,
|
SprintRecordSchema,
|
||||||
SprintsByProjectQuerySchema,
|
SprintsByProjectQuerySchema,
|
||||||
|
SprintUpdateRequestSchema,
|
||||||
StatusCountResponseSchema,
|
StatusCountResponseSchema,
|
||||||
SuccessResponseSchema,
|
SuccessResponseSchema,
|
||||||
TimerEndRequestSchema,
|
TimerEndRequestSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user