Skip to content

Commit

Permalink
Create pubs from forms (#687)
Browse files Browse the repository at this point in the history
* WIP create pub via form

* Fix default values

* Fix default submit button css

* Undefined defaults

* Get form creating pubs

* Workaround for saving not always going through

* Make sure upload id is right

* Fix vector3 default

* Clean up

* Add copy button

* Fix url for copying a form

* Fix pub id passing around

* Add basic test

* Update creating pub behavior to check against all pub fields in community as opposed to pub type

Otherwise creating a pub from a form can error out since the form might have pubfields not in the pubtype

* Remove unused import

* Add new schema types to `getDefaultValueByCoreSchemaType`

* Try to update playwright

---------

Co-authored-by: Kalil Smith-Nuevelle <[email protected]>
  • Loading branch information
allisonking and kalilsn authored Oct 8, 2024
1 parent f2e73c1 commit 33dbddc
Show file tree
Hide file tree
Showing 14 changed files with 410 additions and 175 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Type } from "@sinclair/typebox";
import partition from "lodash.partition";
import { useForm } from "react-hook-form";
import { getJsonSchemaByCoreSchemaType } from "schemas";
import { getDefaultValueByCoreSchemaType, getJsonSchemaByCoreSchemaType } from "schemas";

import type { GetPubResponseBody, JsonValue } from "contracts";
import type { PubsId } from "db/public";
import type { PubsId, PubTypesId } from "db/public";
import { CoreSchemaType, ElementType } from "db/public";
import { Form } from "ui/form";
import { cn } from "utils";
Expand All @@ -26,6 +26,7 @@ import type { PubValues } from "~/lib/server";
import type { Form as PubPubForm } from "~/lib/server/form";
import { isButtonElement } from "~/app/components/FormBuilder/types";
import { useFormElementToggleContext } from "~/app/components/forms/FormElementToggleContext";
import { useCommunity } from "~/app/components/providers/CommunityProvider";
import * as actions from "~/app/components/pubs/PubEditor/actions";
import { didSucceed, useServerAction } from "~/lib/serverActions";
import { SAVE_STATUS_QUERY_PARAM, SUBMIT_ID_QUERY_PARAM } from "./constants";
Expand Down Expand Up @@ -71,19 +72,22 @@ const preparePayload = ({
};

/**
* Date pubValues need to be transformed to a Date type to pass validation
* Set all default values
* Special case: date pubValues need to be transformed to a Date type to pass validation
*/
const buildDefaultValues = (elements: PubPubForm["elements"], pubValues: PubValues) => {
const defaultValues: FieldValues = { ...pubValues };
const dateElements = elements.filter((e) => e.schemaName === CoreSchemaType.DateTime);
for (const de of dateElements) {
if (de.slug) {
const pubValue = pubValues[de.slug];
if (pubValue) {
defaultValues[de.slug] = new Date(pubValue as string);
for (const element of elements) {
if (element.slug && element.schemaName) {
const pubValue = pubValues[element.slug];
defaultValues[element.slug] =
pubValue ?? getDefaultValueByCoreSchemaType(element.schemaName);
if (element.schemaName === CoreSchemaType.DateTime && pubValue) {
defaultValues[element.slug] = new Date(pubValue as string);
}
}
}

return defaultValues;
};

Expand Down Expand Up @@ -126,28 +130,38 @@ const createSchemaFromElements = (
};

export const ExternalFormWrapper = ({
pub,
elements,
className,
children,
isUpdating,
pub,
}: {
pub: GetPubResponseBody;
elements: PubPubForm["elements"];
children: ReactNode;
isUpdating: boolean;
className?: string;
pub: Pick<GetPubResponseBody, "id" | "values" | "pubTypeId">;
}) => {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const community = useCommunity();
const [saveTimer, setSaveTimer] = useState<NodeJS.Timeout>();
const runUpdatePub = useServerAction(actions.updatePub);
const runCreatePub = useServerAction(actions.createPubRecursive);
// Cache pubId
const [pubId, _] = useState<PubsId>(pub.id as PubsId);

const [buttonElements, formElements] = useMemo(
() => partition(elements, (e) => isButtonElement(e)),
[elements]
);
const toggleContext = useFormElementToggleContext();

const defaultValues = useMemo(() => {
return buildDefaultValues(formElements, pub.values);
}, [formElements, pub]);

const handleSubmit = useCallback(
async (
formValues: FieldValues,
Expand All @@ -162,14 +176,30 @@ export const ExternalFormWrapper = ({
const submitButtonId = evt?.nativeEvent.submitter?.id;
const submitButtonConfig = buttonElements.find((b) => b.elementId === submitButtonId);
const stageId = submitButtonConfig?.stageId ?? undefined;
const result = await runUpdatePub({
pubId: pub.id as PubsId,
pubValues,
stageId,
});
let result;
if (isUpdating) {
result = await runUpdatePub({
pubId: pubId,
pubValues,
stageId,
});
} else {
result = await runCreatePub({
body: {
id: pubId,
pubTypeId: pub.pubTypeId as PubTypesId,
values: pubValues as Record<string, any>,
stageId: stageId,
},
communityId: community.id,
});
}
if (didSucceed(result)) {
const newParams = new URLSearchParams(params);
const currentTime = `${new Date().getTime()}`;
if (!isUpdating) {
newParams.set("pubId", pubId);
}
if (!autoSave && isComplete(formElements, pubValues)) {
const submitButtonId = evt?.nativeEvent.submitter?.id;
if (submitButtonId) {
Expand All @@ -182,7 +212,7 @@ export const ExternalFormWrapper = ({
router.replace(`${pathname}?${newParams.toString()}`, { scroll: false });
}
},
[formElements, router, pathname, runUpdatePub, pub, toggleContext]
[formElements, router, pathname, runUpdatePub, pub, community.id, toggleContext]
);

const resolver = useMemo(
Expand All @@ -192,7 +222,7 @@ export const ExternalFormWrapper = ({

const formInstance = useForm({
resolver,
defaultValues: buildDefaultValues(formElements, pub.values),
defaultValues,
shouldFocusError: false,
reValidateMode: "onBlur",
});
Expand All @@ -206,6 +236,10 @@ export const ExternalFormWrapper = ({

const handleAutoSave = useCallback(
(values: FieldValues, evt: React.BaseSyntheticEvent<SubmitEvent> | undefined) => {
// Only autosave on updating a pub, not on creating
if (!isUpdating) {
return;
}
// Don't auto save while editing the user ID field. the query params
// will clash and it will be a bad time :(
const { name } = evt?.target as HTMLInputElement;
Expand All @@ -232,7 +266,7 @@ export const ExternalFormWrapper = ({
<form
onChange={formInstance.handleSubmit(handleAutoSave)}
onSubmit={formInstance.handleSubmit(handleSubmit)}
className={cn("relative flex flex-col gap-6", className)}
className={cn("relative isolate flex flex-col gap-6", className)}
>
{children}
<hr />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@ export const SubmitButtons = ({
// Use a default button if the user does not have buttons configured
if (buttons.length === 0) {
return (
<Button
id="submit-button-default"
type="submit"
disabled={isDisabled}
className={className}
>
Submit
</Button>
<div className={className}>
<Button id="submit-button-default" type="submit" disabled={isDisabled}>
Submit
</Button>
</div>
);
}
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomUUID } from "crypto";

import type { Metadata } from "next";
import type { ReactNode } from "react";

Expand Down Expand Up @@ -32,7 +34,7 @@ const NotFound = ({ children }: { children: ReactNode }) => {

const Completed = ({ element }: { element: Form["elements"][number] | undefined }) => {
return (
<div className="flex w-full flex-col gap-2 pt-32 text-center">
<div data-testid="completed" className="flex w-full flex-col gap-2 pt-32 text-center">
{element ? (
<div
className="prose self-center text-center"
Expand Down Expand Up @@ -154,10 +156,6 @@ export default async function FormPage({
if (!form) {
return <NotFound>No form found</NotFound>;
}
// TODO: eventually, we will be able to create a pub
if (!pub) {
return <NotFound>No pub found</NotFound>;
}

const { user, session } = await getLoginData();

Expand Down Expand Up @@ -199,7 +197,7 @@ export default async function FormPage({
}
}

const parentPub = pub.parentId ? await getPub(pub.parentId as PubsId) : undefined;
const parentPub = pub?.parentId ? await getPub(pub.parentId as PubsId) : undefined;

const member = expect(user.memberships.find((m) => m.communityId === community?.id));

Expand All @@ -211,33 +209,50 @@ export default async function FormPage({
id: user.id as UsersId,
},
};
const renderWithPubContext = {
communityId: community.id,
recipient: memberWithUser,
communitySlug: params.communitySlug,
pub,
parentPub,
};

const submitId: string | undefined = searchParams[SUBMIT_ID_QUERY_PARAM];
const submitElement = form.elements.find((e) => isButtonElement(e) && e.elementId === submitId);

if (submitId && submitElement) {
submitElement.content = await renderElementMarkdownContent(
submitElement,
renderWithPubContext
);
} else {
const elementsWithMarkdownContent = form.elements.filter(
(element) => element.element === StructuralFormElement.p
);
await Promise.all(
elementsWithMarkdownContent.map(async (element) => {
element.content = await renderElementMarkdownContent(element, renderWithPubContext);
})
);
// The post-submission page will only render once we have a pub
if (pub) {
if (submitId && submitElement) {
const renderWithPubContext = {
communityId: community.id,
recipient: memberWithUser,
communitySlug: params.communitySlug,
pub,
parentPub,
};
submitElement.content = await renderElementMarkdownContent(
submitElement,
renderWithPubContext
);
} else {
const renderWithPubContext = {
communityId: community.id,
recipient: memberWithUser,
communitySlug: params.communitySlug,
pub,
parentPub,
};
const elementsWithMarkdownContent = form.elements.filter(
(element) => element.element === StructuralFormElement.p
);
await Promise.all(
elementsWithMarkdownContent.map(async (element) => {
element.content = await renderElementMarkdownContent(
element,
renderWithPubContext
);
})
);
}
}

const isUpdating = !!pub;
const pubId = pub?.id ?? (randomUUID() as PubsId);
const pubForForm = pub ?? { id: pubId, values: {}, pubTypeId: form.pubTypeId };

return (
<div className="isolate min-h-screen">
<Header>
Expand All @@ -260,18 +275,19 @@ export default async function FormPage({
)}
>
<ExternalFormWrapper
pub={pub}
pub={pubForForm}
elements={form.elements}
isUpdating={isUpdating}
className="col-span-2 col-start-2"
>
{form.elements.map((e) => (
<FormElement
key={e.elementId}
pubId={pub.id as PubsId}
pubId={pubId as PubsId}
element={e}
searchParams={searchParams}
communitySlug={params.communitySlug}
values={pub.values}
values={pub ? pub.values : {}}
/>
))}
</ExternalFormWrapper>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";

import { CopyButton } from "ui/copy-button";

import { useCommunity } from "~/app/components/providers/CommunityProvider";

export const FormCopyButton = ({ formSlug }: { formSlug: string }) => {
const community = useCommunity();
const link = `${window.location.origin}/c/${community.slug}/public/forms/${formSlug}/fill`;
return (
<CopyButton className="flex h-8 w-auto gap-1 p-3" value={link}>
Copy link to live form
</CopyButton>
);
};
4 changes: 3 additions & 1 deletion core/app/c/[communitySlug]/forms/[formSlug]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getForm } from "~/lib/server/form";
import { getPubFields } from "~/lib/server/pubFields";
import { ContentLayout } from "../../../ContentLayout";
import { EditFormTitleButton } from "./EditFormTitleButton";
import { FormCopyButton } from "./FormCopyButton";

const getCommunityStages = (communityId: CommunitiesId) =>
db.selectFrom("stages").where("stages.communityId", "=", communityId).selectAll();
Expand Down Expand Up @@ -61,7 +62,8 @@ export default async function Page({
</>
}
headingAction={
<div className="flex gap-2">
<div className="flex items-center gap-2">
<FormCopyButton formSlug={formSlug} />
{/* <ArchiveFormButton id={form.id} className="border border-slate-950 px-4" />{" "} */}
<SaveFormButton form={formBuilderId} />
</div>
Expand Down
7 changes: 6 additions & 1 deletion core/app/components/MemberSelect/MemberSelectClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,13 @@ export function MemberSelectClient({

const onInputValueChange = useDebouncedCallback((value: string) => {
const newParams = new URLSearchParams(params);
const oldParams = newParams.toString();
newParams.set(queryParamName, value);
router.replace(`${pathname}?${newParams.toString()}`, { scroll: false });
// Only change params when they are different, otherwise can cause race conditions
// if another component is trying to change the query params as well
if (oldParams !== newParams.toString()) {
router.replace(`${pathname}?${newParams.toString()}`, { scroll: false });
}
setInputValue(value);
}, 400);

Expand Down
9 changes: 8 additions & 1 deletion core/app/components/forms/elements/FileUploadElement.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState } from "react";
import dynamic from "next/dynamic";
import { Value } from "@sinclair/typebox/value";
import { useFormContext } from "react-hook-form";
Expand All @@ -22,7 +23,13 @@ const FileUpload = dynamic(
}
);

export const FileUploadElement = ({ pubId, name, config }: ElementProps & { pubId: PubsId }) => {
export const FileUploadElement = ({
pubId: propsPubId,
name,
config,
}: ElementProps & { pubId: PubsId }) => {
// Cache the pubId which might be coming from a server side generated randomUuid() that changes
const [pubId, _] = useState(propsPubId);
const signedUploadUrl = (fileName: string) => {
return upload(pubId, fileName);
};
Expand Down
Loading

0 comments on commit 33dbddc

Please sign in to comment.