fix: /routing-form/single - Don't clear edits when moving from one tab to the other (#8125)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
pull/9978/head^2
GitStart-Cal.com 2023-07-10 10:24:04 -03:00 committed by GitHub
parent 045828d4ab
commit e0ef30eb52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 114 additions and 22 deletions

View File

@ -46,7 +46,7 @@ function getRoute(appName: string, pages: string[]) {
} as NotFound;
}
const mainPage = pages[0];
const appPage = routingConfig[mainPage] as AppPageType;
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
if (!appPage) {
return {

View File

@ -2,7 +2,7 @@ import type { App_RoutingForms_Form, Team } from "@prisma/client";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { ShellMain } from "@calcom/features/shell/Shell";
import useApp from "@calcom/lib/hooks/useApp";
@ -247,19 +247,39 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<Response>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
function testRouting() {
const action = processRoute({ form, response });
setDecidedAction(action);
}
const hookForm = useForm({
defaultValues: form,
});
const hookForm = useFormContext<RoutingFormWithResponseCount>();
useEffect(() => {
hookForm.reset(form);
}, [form, hookForm]);
// The first time a tab is opened, the hookForm copies the form data (saved version, from the backend),
// and then it is considered the source of truth.
// There are two events we need to overwrite the hookForm data with the form data coming from the server.
// 1 - When we change the edited form.
// 2 - When the form is saved elsewhere (such as in another browser tab)
// In the second case. We skipped the first execution of useEffect to differentiate a tab change from a form change,
// because each time a tab changes, a new component is created and another useEffect is executed.
// An update from the form always occurs after the first useEffect execution.
if (Object.keys(hookForm.getValues()).length === 0 || hookForm.getValues().id !== form.id) {
hookForm.reset(form);
}
if (skipFirstUpdate) {
setSkipFirstUpdate(false);
} else {
hookForm.reset(form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {

View File

@ -1,6 +1,7 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
import * as LayoutHandler from "./layout-handler/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as Router from "./router/[...appPages]";
@ -13,6 +14,7 @@ const routingConfig = {
"routing-link": RoutingLink,
router: Router,
reporting: Reporting,
layoutHandler: LayoutHandler,
};
export default routingConfig;

View File

@ -1,6 +1,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useWatch } from "react-hook-form";
import { Controller, useFieldArray } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
@ -145,6 +146,17 @@ function Field({
.join("\n")
);
};
const label = useWatch({
control: hookForm.control,
name: `${hookFieldNamespace}.label`,
});
const identifier = useWatch({
control: hookForm.control,
name: `${hookFieldNamespace}.identifier`,
});
return (
<div
data-testid="field"
@ -160,6 +172,7 @@ function Field({
<div className="w-full">
<div className="mb-6 w-full">
<TextField
data-testid={`${hookFieldNamespace}.label`}
disabled={!!router}
label="Label"
className="flex-grow"
@ -168,7 +181,7 @@ function Field({
* This is a bit of a hack to make sure that for routerField, label is shown from there.
* For other fields, value property is used because it exists and would take precedence
*/
defaultValue={routerField?.label}
defaultValue={label || routerField?.label || ""}
required
{...hookForm.register(`${hookFieldNamespace}.label`)}
/>
@ -183,10 +196,7 @@ function Field({
//This change has the same effects that already existed in relation to this field,
// but written in a different way.
// The identifier field will have the same value as the label field until it is changed
defaultValue={
hookForm.watch(`${hookFieldNamespace}.identifier`) ||
hookForm.watch(`${hookFieldNamespace}.label`)
}
value={identifier || routerField?.identifier || label || routerField?.label || ""}
onChange={(e) => {
hookForm.setValue(`${hookFieldNamespace}.identifier`, e.target.value);
}}

View File

@ -1,5 +1,7 @@
// TODO: i18n
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useFormContext } from "react-hook-form";
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
import { FilterResults } from "@calcom/features/filters/components/FilterResults";
@ -46,6 +48,7 @@ import {
FormActionsDropdown,
FormActionsProvider,
} from "../../components/FormActions";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import { isFallbackRoute } from "../../lib/isFallbackRoute";
function NewFormButton() {
@ -69,6 +72,11 @@ export default function RoutingForms({
const { hasPaidPlan } = useHasPaidPlan();
const router = useRouter();
const hookForm = useFormContext<RoutingFormWithResponseCount>();
useEffect(() => {
hookForm.reset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filters = getTeamsFiltersFromQuery(router.query);
const queryRes = trpc.viewer.appRoutingForms.forms.useQuery({

View File

@ -0,0 +1,52 @@
import type { GetServerSidePropsContext } from "next";
import type { NextRouter } from "next/router";
import { useRouter } from "next/router";
import React from "react";
import { useForm, FormProvider } from "react-hook-form";
import type { AppPrisma, AppSsrInit, AppUser } from "@calcom/types/AppGetServerSideProps";
import type { AppProps } from "@lib/app-providers";
import RoutingFormsRoutingConfig from "../app-routing.config";
type GetServerSidePropsRestArgs = [AppPrisma, AppUser, AppSsrInit];
type Component = {
default: React.ComponentType & Pick<AppProps["Component"], "getLayout">;
getServerSideProps?: (context: GetServerSidePropsContext, ...rest: GetServerSidePropsRestArgs) => void;
};
const getComponent = (route: string | NextRouter): Component => {
const defaultRoute = "forms";
const routeKey =
typeof route === "string" ? route || defaultRoute : route?.query?.pages?.[0] || defaultRoute;
return (RoutingFormsRoutingConfig as unknown as Record<string, Component>)[routeKey];
};
export default function LayoutHandler(props: { [key: string]: unknown }) {
const methods = useForm();
const router = useRouter();
const pageKey = router?.query?.pages?.[0] || "forms";
const PageComponent = getComponent(pageKey).default;
return (
<FormProvider {...methods}>
<PageComponent {...props} />
</FormProvider>
);
}
LayoutHandler.getLayout = (page: React.ReactElement, router: NextRouter) => {
const component = getComponent(router).default;
if (component && "getLayout" in component) {
return component.getLayout?.(page, router);
} else {
return page;
}
};
export async function getServerSideProps(
context: GetServerSidePropsContext,
...rest: GetServerSidePropsRestArgs
) {
const component = getComponent(context.params?.pages?.[0] || "");
return component.getServerSideProps?.(context, ...rest) || { props: {} };
}

View File

@ -4,6 +4,7 @@ import React, { useCallback, useState } from "react";
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import type { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import type { UseFormReturn } from "react-hook-form";
import Shell from "@calcom/features/shell/Shell";
import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
@ -20,6 +21,7 @@ import {
Divider,
} from "@calcom/ui";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
@ -304,15 +306,13 @@ const Routes = ({
appUrl,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
// Figure out the type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hookForm: any;
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
appUrl: string;
}) => {
const { routes: serializedRoutes } = form;
const { routes: serializedRoutes } = hookForm.getValues();
const { t } = useLocale();
const config = getQueryBuilderConfig(form);
const config = getQueryBuilderConfig(hookForm.getValues());
const [routes, setRoutes] = useState(() => {
const transformRoutes = () => {
const _routes = serializedRoutes || [getEmptyRoute()];
@ -343,11 +343,11 @@ const Routes = ({
userId: router.userId,
},
entity2: {
teamId: form.teamId ?? null,
userId: form.userId,
teamId: hookForm.getValues().teamId ?? null,
userId: hookForm.getValues().userId,
},
});
return router.id !== form.id && routerValidInContext;
return router.id !== hookForm.getValues().id && routerValidInContext;
})
.map(({ form: router }) => {
return {

View File

@ -397,7 +397,7 @@ async function expectCurrentFormToHaveFields(
types: string[]
) {
for (const [index, field] of Object.entries(fields)) {
expect(await page.inputValue(`[name="fields.${index}.label"]`)).toBe(field.label);
expect(await page.inputValue(`[data-testid="fields.${index}.label"]`)).toBe(field.label);
expect(await page.locator(".data-testid-field-type").nth(+index).locator("div").nth(1).innerText()).toBe(
types[field.typeIndex]
);
@ -519,7 +519,7 @@ export async function addOneFieldAndDescriptionAndSaveForm(
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
if (form.field) {
await page.fill(`[name="fields.${nextFieldIndex}.label"]`, form.field.label);
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)