diff --git a/apps/hub/src/app/ai/WorkflowViewer/PublishWorkflowButton.tsx b/apps/hub/src/app/ai/WorkflowViewer/PublishWorkflowButton.tsx new file mode 100644 index 0000000000..c541fc606e --- /dev/null +++ b/apps/hub/src/app/ai/WorkflowViewer/PublishWorkflowButton.tsx @@ -0,0 +1,70 @@ +import { useRouter } from "next/navigation"; +import { FC, useState } from "react"; + +import { ClientWorkflow } from "@quri/squiggle-ai"; +import { Button } from "@quri/ui"; + +import { NewModelFormBody, NewModelFormShape } from "@/app/new/model/NewModel"; +import { SafeActionFormModal } from "@/components/ui/SafeActionFormModal"; +import { createModelAction } from "@/models/actions/createModelAction"; + +type Props = { + workflow: Extract; +}; + +const PublishWorkflowModal: FC void }> = ({ + workflow, + close, +}) => { + const router = useRouter(); + + const code = `/* +Generated by Squiggle AI. Workflow ID: ${workflow.id} +*/ +${workflow.result.code}`; + + return ( + + title="Publish Model" + action={createModelAction} + close={close} + defaultValues={{ + // TODO - LLM-generated slug + isPrivate: false, + }} + formDataToInput={(data) => ({ + code, + slug: data.slug ?? "", + groupSlug: data.group?.slug, + isPrivate: data.isPrivate, + })} + submitText="Save" + onSuccess={(data) => { + // Note: redirect in server action would be incompatible with https://github.com/TheEdoRan/next-safe-action/issues/303 + // (and might a bad idea anyway, returning a url is more verbose but more flexible for reuse) + router.push(data.url); + }} + closeOnSuccess={false} + > + + + ); +}; + +export const PublishWorkflowButton: FC = ({ workflow }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + {isOpen && ( + setIsOpen(false)} + /> + )} + + ); +}; diff --git a/apps/hub/src/app/ai/WorkflowViewer/index.tsx b/apps/hub/src/app/ai/WorkflowViewer/index.tsx index 2b5e60c035..a60e8a7791 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/index.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/index.tsx @@ -9,6 +9,7 @@ import { useAvailableHeight } from "@/lib/hooks/useAvailableHeight"; import { LogsView } from "../LogsView"; import { SquigglePlaygroundForWorkflow } from "../SquigglePlaygroundForWorkflow"; import { Header } from "./Header"; +import { PublishWorkflowButton } from "./PublishWorkflowButton"; import { WorkflowSteps } from "./WorkflowSteps"; type WorkflowViewerProps< @@ -56,7 +57,8 @@ const FinishedWorkflowViewer: FC> = ({ /> )} renderRight={() => ( -
+
+ diff --git a/apps/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx b/apps/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx index c218c1842b..1497b39e82 100644 --- a/apps/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx +++ b/apps/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx @@ -1,10 +1,14 @@ import { MembershipRole } from "@prisma/client"; import { FC } from "react"; -import { PlusIcon, SelectStringFormField } from "@quri/ui"; +import { + DropdownMenuModalActionItem, + PlusIcon, + SelectStringFormField, +} from "@quri/ui"; import { SelectUser, SelectUserOption } from "@/components/SelectUser"; -import { SafeActionModalAction } from "@/components/ui/SafeActionModalAction"; +import { SafeActionFormModal } from "@/components/ui/SafeActionFormModal"; import { addUserToGroupAction } from "@/groups/actions/addUserToGroupAction"; import { GroupMemberDTO } from "@/groups/data/members"; @@ -17,33 +21,36 @@ type FormShape = { user: SelectUserOption; role: MembershipRole }; export const AddUserToGroupAction: FC = ({ groupSlug, append }) => { return ( - + { - append(membership); - }} - defaultValues={{ role: "Member" }} - formDataToInput={(data) => ({ - group: groupSlug, - username: data.user.slug, - role: data.role, - })} - submitText="Add" - modalTitle={`Add to group ${groupSlug}`} - > - {() => ( -
- label="User" name="user" /> - - name="role" - label="Role" - options={["Member", "Admin"]} - required - /> -
+ render={({ close }) => ( + + close={close} + action={addUserToGroupAction} + onSuccess={(membership) => { + append(membership); + }} + defaultValues={{ role: "Member" }} + formDataToInput={(data) => ({ + group: groupSlug, + username: data.user.slug, + role: data.role, + })} + submitText="Add" + title={`Add to group ${groupSlug}`} + > +
+ label="User" name="user" /> + + name="role" + label="Role" + options={["Member", "Admin"]} + required + /> +
+ )} - + /> ); }; diff --git a/apps/hub/src/app/models/[owner]/[slug]/ModelSettingsButton.tsx b/apps/hub/src/app/models/[owner]/[slug]/ModelSettingsButton.tsx index f92f25e845..7102c6f0bc 100644 --- a/apps/hub/src/app/models/[owner]/[slug]/ModelSettingsButton.tsx +++ b/apps/hub/src/app/models/[owner]/[slug]/ModelSettingsButton.tsx @@ -15,9 +15,9 @@ export const ModelSettingsButton: FC<{ }> = ({ model }) => { return ( ( + render={() => ( - + diff --git a/apps/hub/src/app/models/[owner]/[slug]/MoveModelAction.tsx b/apps/hub/src/app/models/[owner]/[slug]/MoveModelAction.tsx index 25470b4401..62240965b9 100644 --- a/apps/hub/src/app/models/[owner]/[slug]/MoveModelAction.tsx +++ b/apps/hub/src/app/models/[owner]/[slug]/MoveModelAction.tsx @@ -1,10 +1,10 @@ import { useRouter } from "next/navigation"; import { FC } from "react"; -import { RightArrowIcon } from "@quri/ui"; +import { DropdownMenuModalActionItem, RightArrowIcon } from "@quri/ui"; import { SelectOwner, SelectOwnerOption } from "@/components/SelectOwner"; -import { SafeActionModalAction } from "@/components/ui/SafeActionModalAction"; +import { SafeActionFormModal } from "@/components/ui/SafeActionFormModal"; import { modelRoute } from "@/lib/routes"; import { moveModelAction } from "@/models/actions/moveModelAction"; import { ModelCardDTO } from "@/models/data/cards"; @@ -21,42 +21,45 @@ export const MoveModelAction: FC = ({ model }) => { const router = useRouter(); return ( - + ({ - oldOwner: model.owner.slug, - owner: { slug: data.owner.slug }, - slug: model.slug, - })} - onSuccess={({ model: newModel }) => { - draftUtils.rename( - modelToDraftLocator(model), - modelToDraftLocator(newModel) - ); - router.push( - modelRoute({ owner: newModel.owner.slug, slug: newModel.slug }) - ); - }} icon={RightArrowIcon} - initialFocus="owner" - blockOnSuccess - > - {() => ( -
+ render={({ close }) => ( + + close={close} + title={`Change owner for ${model.owner.slug}/${model.slug}`} + submitText="Save" + defaultValues={{ + // __typename from fragment is string, while SelectOwner requires 'User' | 'Group' union, + // so we have to explicitly recast + owner: model.owner as SelectOwnerOption, + }} + action={moveModelAction} + formDataToInput={(data) => ({ + oldOwner: model.owner.slug, + owner: { slug: data.owner.slug }, + slug: model.slug, + })} + onSuccess={({ model: newModel }) => { + draftUtils.rename( + modelToDraftLocator(model), + modelToDraftLocator(newModel) + ); + router.push( + modelRoute({ owner: newModel.owner.slug, slug: newModel.slug }) + ); + }} + closeOnSuccess={false} + initialFocus="owner" + >
- Are you sure? All existing links to the model will break. +
+ Are you sure? All existing links to the model will break. +
+ name="owner" label="New owner" myOnly />
- name="owner" label="New owner" myOnly /> -
+ )} - + /> ); }; diff --git a/apps/hub/src/app/models/[owner]/[slug]/UpdateModelSlugAction.tsx b/apps/hub/src/app/models/[owner]/[slug]/UpdateModelSlugAction.tsx index 5cd77d72b1..80a16d4559 100644 --- a/apps/hub/src/app/models/[owner]/[slug]/UpdateModelSlugAction.tsx +++ b/apps/hub/src/app/models/[owner]/[slug]/UpdateModelSlugAction.tsx @@ -1,9 +1,9 @@ import { useRouter } from "next/navigation"; import { FC } from "react"; -import { EditIcon } from "@quri/ui"; +import { DropdownMenuModalActionItem, EditIcon } from "@quri/ui"; -import { SafeActionModalAction } from "@/components/ui/SafeActionModalAction"; +import { SafeActionFormModal } from "@/components/ui/SafeActionFormModal"; import { SlugFormField } from "@/components/ui/SlugFormField"; import { modelRoute } from "@/lib/routes"; import { updateModelSlugAction } from "@/models/actions/updateModelSlugAction"; @@ -13,46 +13,49 @@ import { draftUtils, modelToDraftLocator } from "./SquiggleSnippetDraftDialog"; type Props = { model: ModelCardDTO; - close(): void; }; type FormShape = { slug: string }; -export const UpdateModelSlugAction: FC = ({ model, close }) => { +export const UpdateModelSlugAction: FC = ({ model }) => { const router = useRouter(); return ( - + ({ - owner: model.owner.slug, - oldSlug: model.slug, - slug: data.slug, - })} - onSuccess={({ model: newModel }) => { - draftUtils.rename( - modelToDraftLocator(model), - modelToDraftLocator(newModel) - ); - router.push( - modelRoute({ owner: newModel.owner.slug, slug: newModel.slug }) - ); - }} - submitText="Save" - modalTitle={`Rename ${model.owner.slug}/${model.slug}`} - initialFocus="slug" - > - {() => ( -
-
- Are you sure? All existing links to the model will break. + render={({ close }) => ( + + close={close} + action={updateModelSlugAction} + defaultValues={{ slug: model.slug }} + formDataToInput={(data) => ({ + owner: model.owner.slug, + oldSlug: model.slug, + slug: data.slug, + })} + onSuccess={({ model: newModel }) => { + draftUtils.rename( + modelToDraftLocator(model), + modelToDraftLocator(newModel) + ); + router.push( + modelRoute({ owner: newModel.owner.slug, slug: newModel.slug }) + ); + }} + closeOnSuccess={false} + submitText="Save" + title={`Rename ${model.owner.slug}/${model.slug}`} + initialFocus="slug" + > +
+
+ Are you sure? All existing links to the model will break. +
+ name="slug" label="New slug" />
- name="slug" label="New slug" /> -
+ )} - + /> ); }; diff --git a/apps/hub/src/app/new/group/NewGroup.tsx b/apps/hub/src/app/new/group/NewGroup.tsx index 592d5b3bbe..dc0f52e192 100644 --- a/apps/hub/src/app/new/group/NewGroup.tsx +++ b/apps/hub/src/app/new/group/NewGroup.tsx @@ -47,9 +47,9 @@ export const NewGroup: FC = () => { />
diff --git a/apps/hub/src/app/new/model/NewModel.tsx b/apps/hub/src/app/new/model/NewModel.tsx index f181e9f9ae..5bc7793206 100644 --- a/apps/hub/src/app/new/model/NewModel.tsx +++ b/apps/hub/src/app/new/model/NewModel.tsx @@ -1,96 +1,84 @@ "use client"; -import { useAction } from "next-safe-action/hooks"; import { useRouter } from "next/navigation"; import { FC, useState } from "react"; -import { FormProvider, useForm } from "react-hook-form"; +import { FormProvider } from "react-hook-form"; -import { Button, CheckboxFormField, useToast } from "@quri/ui"; +import { Button, CheckboxFormField } from "@quri/ui"; import { SelectGroup, SelectGroupOption } from "@/components/SelectGroup"; import { H1 } from "@/components/ui/Headers"; import { SlugFormField } from "@/components/ui/SlugFormField"; +import { useSafeActionForm } from "@/lib/hooks/useSafeActionForm"; import { createModelAction } from "@/models/actions/createModelAction"; -type FormShape = { +export type NewModelFormShape = { slug: string | undefined; group: SelectGroupOption | null; isPrivate: boolean; }; +export const NewModelFormBody: FC = () => { + return ( + // reused in AI workflows +
+ + name="slug" + example="my-long-model" + label="Model Name" + placeholder="my-model" + /> + + label="Group" + description="Optional. Models owned by a group are editable by all members of the group." + name="group" + required={false} + myOnly + /> + label="Private" name="isPrivate" /> +
+ ); +}; + export const NewModel: FC<{ initialGroup: SelectGroupOption | null }> = ({ initialGroup, }) => { const [group] = useState(initialGroup); - const toast = useToast(); const router = useRouter(); - const { executeAsync, isPending } = useAction(createModelAction, { - onSuccess: ({ data }) => { - if (data) { - // redirect in action is incompatible with https://github.com/TheEdoRan/next-safe-action/issues/303 - // (and might a bad idea anyway, returning an url is more verbose but more flexible for reuse) - router.push(data.url); - } - }, - onError: ({ error }) => { - if (error.serverError) { - toast(error.serverError, "error"); - return; - } - - const slugError = error.validationErrors?.slug?._errors?.[0]; - if (slugError) { - form.setError("slug", { - message: slugError, - }); - } else { - toast("Internal error", "error"); - } - }, - }); - - const form = useForm({ + const { form, onSubmit, inFlight } = useSafeActionForm< + NewModelFormShape, + typeof createModelAction + >({ 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 + // don't pass `slug: ""` here, it will lead to form reset if the user starts to type in a value before JS loading finishes group, isPrivate: false, }, - }); - - const onSubmit = form.handleSubmit(async (data) => { - await executeAsync({ + formDataToInput: (data) => ({ slug: data.slug ?? "", // shouldn't happen but satisfies Typescript groupSlug: data.group?.slug, isPrivate: data.isPrivate, - }); + }), + action: createModelAction, + blockOnSuccess: true, + onSuccess: (data) => { + // Note: redirect in server action would be incompatible with https://github.com/TheEdoRan/next-safe-action/issues/303 + // (and might a bad idea anyway, returning a url is more verbose but more flexible for reuse) + router.push(data.url); + }, }); return (

New Model

-
- - name="slug" - example="my-long-model" - label="Model Name" - placeholder="my-model" - /> - - label="Group" - description="Optional. Models owned by a group are editable by all members of the group." - name="group" - required={false} - myOnly={true} - /> - label="Private" name="isPrivate" /> -
+ diff --git a/apps/hub/src/app/settings/choose-username/ChooseUsername.tsx b/apps/hub/src/app/settings/choose-username/ChooseUsername.tsx index fa6b349293..4aec7172ba 100644 --- a/apps/hub/src/app/settings/choose-username/ChooseUsername.tsx +++ b/apps/hub/src/app/settings/choose-username/ChooseUsername.tsx @@ -41,7 +41,7 @@ export const ChooseUsername: FC = () => { label="Pick a username" size="small" /> -
diff --git a/apps/hub/src/components/ui/FormModal.tsx b/apps/hub/src/components/ui/FormModal.tsx index 7d84eba4eb..b880d2121b 100644 --- a/apps/hub/src/components/ui/FormModal.tsx +++ b/apps/hub/src/components/ui/FormModal.tsx @@ -43,7 +43,7 @@ export function FormModal({ {children}
-
diff --git a/packages/ui/src/components/Dropdown/DropdownMenuModalActionItem.tsx b/packages/ui/src/components/Dropdown/DropdownMenuModalActionItem.tsx index 1af6cccef7..ec7c1fcd7b 100644 --- a/packages/ui/src/components/Dropdown/DropdownMenuModalActionItem.tsx +++ b/packages/ui/src/components/Dropdown/DropdownMenuModalActionItem.tsx @@ -1,16 +1,17 @@ "use client"; import { FC, ReactNode, useState } from "react"; +import { useCloseDropdown } from "./DropdownContext.js"; import { DropdownMenuActionItem } from "./DropdownMenuActionItem.js"; import { ItemLayoutProps } from "./DropdownMenuItemLayout.js"; type Props = ItemLayoutProps & { - render(): ReactNode; + render: ({ close }: { close: () => void }) => ReactNode; }; /* * This component doesn't close the dropdown when modal is displayed. - * Instead, you should close the dropdown manually by passing `useCloseDropdown()` result to Modal's `close` prop. + * Instead, you should close the dropdown manually by passing `useCloseDropdown()` result to Modal's `close` prop, or by using `close` prop from `render` function. */ export const DropdownMenuModalActionItem: FC = ({ title, @@ -18,6 +19,7 @@ export const DropdownMenuModalActionItem: FC = ({ render, }) => { const [isOpen, setIsOpen] = useState(false); + const closeDropdown = useCloseDropdown(); return ( <> @@ -26,7 +28,7 @@ export const DropdownMenuModalActionItem: FC = ({ onClick={() => setIsOpen(true)} icon={icon} /> - {isOpen && render()} + {isOpen && render({ close: closeDropdown })} ); };