Skip to content

Commit

Permalink
trying next-safe-action
Browse files Browse the repository at this point in the history
  • Loading branch information
berekuk committed Nov 30, 2024
1 parent bf6ceac commit 7a6ed36
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 128 deletions.
1 change: 1 addition & 0 deletions packages/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"lodash": "^4.17.21",
"next": "^15.0.3",
"next-auth": "5.0.0-beta.25",
"next-safe-action": "^7.9.9",
"nodemailer": "^6.9.13",
"pako": "^2.1.0",
"react": "19.0.0-rc-66855b96-20241106",
Expand Down
71 changes: 35 additions & 36 deletions packages/hub/src/app/new/model/NewModel.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { useAction } from "next-safe-action/hooks";
import { FC, useState } from "react";
import { FormProvider } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";

import { generateSeed } from "@quri/squiggle-lang";
import { Button, CheckboxFormField } from "@quri/ui";
import { defaultSquiggleVersion } from "@quri/versioned-squiggle-components";
import { Button, CheckboxFormField, useToast } from "@quri/ui";

import { SelectGroup, SelectGroupOption } from "@/components/SelectGroup";
import { H1 } from "@/components/ui/Headers";
import { SlugFormField } from "@/components/ui/SlugFormField";
import { useServerActionForm } from "@/lib/hooks/useServerActionForm";
import { modelRoute } from "@/lib/routes";
import { createSquiggleSnippetModelAction } from "@/models/actions/createSquiggleSnippetModelAction";

const defaultCode = `/*
Describe your code here
*/
a = normal(2, 5)
`;
import { createModelAction } from "@/models/actions/createModelAction";

type FormShape = {
slug: string | undefined;
Expand All @@ -32,36 +21,42 @@ export const NewModel: FC<{ initialGroup: SelectGroupOption | null }> = ({
}) => {
const [group] = useState(initialGroup);

const router = useRouter();
const toast = useToast();

const { executeAsync, status } = useAction(createModelAction, {
onError: ({ error, ...rest }) => {
console.trace("onError", error, rest);
if (error.serverError) {
toast(error.serverError, "error");
return;
}

const { form, onSubmit, inFlight } = useServerActionForm<
FormShape,
typeof createSquiggleSnippetModelAction
>({
const slugError = error.validationErrors?.slug?._errors?.[0];
if (slugError) {
form.setError("slug", {
message: slugError,
});
} else {
toast("Internal error", "error");
}
},
});

const form = useForm<FormShape>({
mode: "onChange",
defaultValues: {
// don't pass `slug: ""` here, it will lead to form reset if a user started to type in a value before JS finished loading
group,
isPrivate: false,
},
blockOnSuccess: true,
action: createSquiggleSnippetModelAction,
formDataToVariables: (data) => ({
});

const onSubmit = form.handleSubmit(async (data) => {
await executeAsync({
slug: data.slug ?? "", // shouldn't happen but satisfies Typescript
groupSlug: data.group?.slug,
isPrivate: data.isPrivate,
code: defaultCode,
version: defaultSquiggleVersion,
seed: generateSeed(),
}),
onCompleted: (result) => {
router.push(
modelRoute({
owner: result.model.owner.slug,
slug: result.model.slug,
})
);
},
});
});

return (
Expand All @@ -86,7 +81,11 @@ export const NewModel: FC<{ initialGroup: SelectGroupOption | null }> = ({
</div>
<Button
onClick={onSubmit}
disabled={!form.formState.isValid || inFlight}
disabled={
!form.formState.isValid ||
form.formState.isSubmitting ||
status === "hasSucceeded"
}
theme="primary"
>
Create
Expand Down
21 changes: 20 additions & 1 deletion packages/hub/src/lib/server/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Prisma } from "@prisma/client";
import {
createSafeActionClient,
DEFAULT_SERVER_ERROR_MESSAGE,
} from "next-safe-action";
import { z } from "zod";

export type DeepReadonly<T> = T extends (infer R)[]
Expand All @@ -25,6 +29,20 @@ export function makeServerAction<T, R>(
};
}

export class ActionError extends Error {}

export const actionClient = createSafeActionClient({
handleServerError(e) {
console.error("Action error:", e.message);

if (e instanceof ActionError) {
return e.message;
}

return DEFAULT_SERVER_ERROR_MESSAGE;
},
});

// Rethrows Prisma constraint error (usually happens on create operations) with a nicer error message.
export async function rethrowOnConstraint<T>(
cb: () => Promise<T>,
Expand All @@ -44,7 +62,8 @@ export async function rethrowOnConstraint<T>(
e.meta?.["target"].join(",") === handler.target.join(",")
) {
// TODO - throw more specific error
throw new Error(handler.error);
// TODO - should this even be an ActionError? `handler.error` is still not very readable
throw new ActionError(handler.error);
}
}
throw e;
Expand Down
132 changes: 132 additions & 0 deletions packages/hub/src/models/actions/createModelAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use server";

import { returnValidationErrors } from "next-safe-action";
import { redirect } from "next/navigation";
import { z } from "zod";

import { generateSeed } from "@quri/squiggle-lang";
import { defaultSquiggleVersion } from "@quri/versioned-squiggle-components";

import { modelRoute } from "@/lib/routes";
import { prisma } from "@/lib/server/prisma";
import { actionClient } from "@/lib/server/utils";
import { zSlug } from "@/lib/zodUtils";
import { getWriteableOwner } from "@/owners/data/auth";
import { indexModelId } from "@/search/helpers";
import { getSelf, getSessionOrRedirect } from "@/users/auth";

const defaultCode = `/*
Describe your code here
*/
a = normal(2, 5)
`;

const schema = z.object({
groupSlug: zSlug.optional(),
slug: zSlug.optional(),
isPrivate: z.boolean(),
});

// This action is tightly coupled with the form in NewModel.tsx.
// In particular, it uses the default code, and redirects to the newly created model.
export const createModelAction = actionClient
.schema(schema)
.action(async ({ parsedInput: input }) => {
try {
const slug = input.slug;
if (!slug) {
returnValidationErrors(schema, {
slug: {
_errors: ["Slug is required"],
},
});
}

const session = await getSessionOrRedirect();

const seed = generateSeed();
const version = defaultSquiggleVersion;
const code = defaultCode;

const model = await prisma.$transaction(async (tx) => {
const owner = await getWriteableOwner(session, input.groupSlug);

// nested create is not possible here;
// similar problem is described here: https://github.com/prisma/prisma/discussions/14937,
// seems to be caused by multiple Model -> ModelRevision relations
let model: { id: string };
try {
model = await tx.model.create({
data: {
slug,
ownerId: owner.id,
isPrivate: input.isPrivate,
},
select: { id: true },
});
} catch {
returnValidationErrors(schema, {
slug: {
_errors: [`Model ${input.slug} already exists on this account`],
},
});
}

const self = await getSelf(session);

const revision = await tx.modelRevision.create({
data: {
squiggleSnippet: {
create: {
code,
version,
seed,
},
},
author: {
connect: { id: self.id },
},
contentType: "SquiggleSnippet",
model: {
connect: {
id: model.id,
},
},
},
});

return await tx.model.update({
where: {
id: model.id,
},
data: {
currentRevisionId: revision.id,
},
select: {
id: true,
slug: true,
owner: {
select: {
slug: true,
},
},
},
});
});

await indexModelId(model.id);

redirect(
modelRoute({
owner: model.owner.slug,
slug: model.slug,
})
);

return { model };
} catch (e) {
console.log("action error", e instanceof Error && e.message);
throw e;
}
});

This file was deleted.

0 comments on commit 7a6ed36

Please sign in to comment.