import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; import { useState } from "react"; function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; } = {} ) { return z.discriminatedUnion("action", [ z.object({ action: z.literal("rename"), projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), }), z.object({ action: z.literal("delete"), projectSlug: z.string().superRefine((slug, ctx) => { if (constraints.getSlugMatch === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: conformZodMessage.VALIDATION_UNDEFINED, }); } else { const { isMatch, projectSlug } = constraints.getSlugMatch(slug); if (isMatch) { return; } ctx.addIssue({ code: z.ZodIssueCode.custom, message: `The slug must match ${projectSlug}`, }); } }), }), ]); } export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); const { organizationSlug, projectParam } = params; if (!organizationSlug || !projectParam) { return json( { errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 } ); } const formData = await request.formData(); const schema = createSchema({ getSlugMatch: (slug) => { return { isMatch: slug === projectParam, projectSlug: projectParam }; }, }); const submission = parseWithZod(formData, { schema }); if (submission.status !== "success") { return json(submission.reply()); } const projectSettingsService = new ProjectSettingsService(); const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( organizationSlug, projectParam, userId ); if (membershipResultOrFail.isErr()) { return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); } const { projectId } = membershipResultOrFail.value; switch (submission.value.action) { case "rename": { const resultOrFail = await projectSettingsService.renameProject( projectId, submission.value.projectName ); if (resultOrFail.isErr()) { switch (resultOrFail.error.type) { case "other": default: { resultOrFail.error.type satisfies "other"; logger.error("Failed to rename project", { error: resultOrFail.error, }); return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); } } } return redirectWithSuccessMessage( v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), request, `Project renamed to ${submission.value.projectName}` ); } case "delete": { const resultOrFail = await projectSettingsService.deleteProject(projectId, userId); if (resultOrFail.isErr()) { switch (resultOrFail.error.type) { case "other": default: { resultOrFail.error.type satisfies "other"; logger.error("Failed to delete project", { error: resultOrFail.error, }); return redirectWithErrorMessage( v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), request, `Project ${projectParam} could not be deleted` ); } } } return redirectWithSuccessMessage( organizationPath({ slug: organizationSlug }), request, "Project deleted" ); } } }; export default function GeneralSettingsPage() { const project = useProject(); const lastSubmission = useActionData(); const navigation = useNavigation(); const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); const [renameForm, { projectName }] = useForm({ id: "rename-project", // TODO: type this lastResult: lastSubmission as any, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { schema: createSchema(), }); }, }); const isRenameLoading = navigation.formData?.get("action") === "rename" && (navigation.state === "submitting" || navigation.state === "loading"); const [deleteForm, { projectSlug }] = useForm({ id: "delete-project", // TODO: type this lastResult: lastSubmission as any, shouldValidate: "onInput", shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { schema: createSchema({ getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), }), }); }, }); const isDeleteLoading = navigation.formData?.get("action") === "delete" && (navigation.state === "submitting" || navigation.state === "loading"); const [deleteInputValue, setDeleteInputValue] = useState(""); return (
General
This goes in your{" "} trigger.config file.
{ setHasRenameFormChanges(e.target.value !== project.name); }} /> {projectName.errors} Save } />
Danger zone
setDeleteInputValue(e.target.value)} /> {projectSlug.errors} {deleteForm.errors} This change is irreversible, so please be certain. Type in the Project slug {project.slug} and then press Delete. Delete } />
); }