mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-10 11:24:37 -05:00
Compare commits
2 Commits
fix/apps-w
...
fix/6658-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f5875145 | ||
|
|
8331bd8431 |
2
.husky/post-checkout
Normal file
2
.husky/post-checkout
Normal file
@@ -0,0 +1,2 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
prettier --write ./branch.json
|
||||
@@ -10,20 +10,25 @@
|
||||
"build-storybook": "storybook build",
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.15",
|
||||
"@storybook/addon-links": "10.2.15",
|
||||
"@storybook/addon-onboarding": "10.2.15",
|
||||
"@storybook/react-vite": "10.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "10.1.11",
|
||||
"@storybook/addon-links": "10.1.11",
|
||||
"@storybook/addon-onboarding": "10.1.11",
|
||||
"@storybook/react-vite": "10.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"esbuild": "0.25.12",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.14",
|
||||
"storybook": "10.2.15",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.1.11",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.15"
|
||||
"@storybook/addon-docs": "10.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const baseConfig = require("../../.prettierrc.js");
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
};
|
||||
@@ -101,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
|
||||
) : (
|
||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||
<span className="relative flex h-10 w-10">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -4,10 +4,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const OnboardingLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const OnboardingLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -2,7 +2,6 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
@@ -40,13 +39,13 @@ const mockTemplate: TXMTemplate = {
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
||||
type: "openText" as const,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
subheader: { default: "" },
|
||||
required: false,
|
||||
placeholder: { default: "" },
|
||||
charLimit: { enabled: true, max: 1000 },
|
||||
charLimit: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||
const tMock = vi.fn((key) => key) as TFunction;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||
const tMock = vi.fn((key) => key) as TFunction;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as unknown as TFunction;
|
||||
}) as TFunction;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
|
||||
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const LandingLayout = async (props: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const LandingLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
|
||||
const ProjectOnboardingLayout = async (props: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const ProjectOnboardingLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const OnboardingLayout = async (props: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const OnboardingLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
|
||||
</FormProvider>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
|
||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
|
||||
{logoUrl && (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
@@ -239,16 +239,18 @@ export const ProjectSettings = ({
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
onFileUpload={async (file) => file.name}
|
||||
autoFocus={false}
|
||||
/>
|
||||
<div className="z-0 h-3/4 w-3/4">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || "my Product", t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
onFileUpload={async (file) => file.name}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CreateTeamModal
|
||||
open={createTeamModalOpen}
|
||||
|
||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
|
||||
}
|
||||
|
||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
|
||||
const getOptionCard = (option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<OptionCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrganizationTeam = z.object({
|
||||
id: z.cuid2(),
|
||||
id: z.string().cuid2(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -3,13 +3,13 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const Page = async (props: { searchParams: Promise<{ environmentId?: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const { environmentId } = searchParams;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<ConfirmationPage environmentId={environmentId?.toString() ?? ""} />
|
||||
<ConfirmationPage environmentId={environmentId?.toString()} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const ZCreateProjectAction = z.object({
|
||||
data: ZProjectUpdateInput,
|
||||
});
|
||||
|
||||
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
|
||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"project",
|
||||
@@ -97,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
* Called on-demand when user opens the organization switcher.
|
||||
*/
|
||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
.inputSchema(ZGetOrganizationsForSwitcherAction)
|
||||
.schema(ZGetOrganizationsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -122,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
|
||||
* Called on-demand when user opens the project switcher.
|
||||
*/
|
||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
.inputSchema(ZGetProjectsForSwitcherAction)
|
||||
.schema(ZGetProjectsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -115,21 +114,14 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.workflows"),
|
||||
href: `/environments/${environment.id}/workflows`,
|
||||
icon: WorkflowIcon,
|
||||
isActive: pathname?.includes("/workflows"),
|
||||
isHidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
isActive: pathname?.includes("/project"),
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
[t, environment.id, pathname]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
@@ -196,7 +188,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const EnvironmentPage = async (props) => {
|
||||
const params = await props.params;
|
||||
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const AccountSettingsLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const AccountSettingsLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
|
||||
});
|
||||
|
||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateNotificationSettingsAction)
|
||||
.schema(ZUpdateNotificationSettingsAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
|
||||
@@ -16,8 +16,8 @@ const setCompleteNotificationSettings = (
|
||||
notificationSettings: TUserNotificationSettings,
|
||||
memberships: Membership[]
|
||||
): TUserNotificationSettings => {
|
||||
const newNotificationSettings: TUserNotificationSettings = {
|
||||
alert: {} as Record<string, boolean>,
|
||||
const newNotificationSettings = {
|
||||
alert: {},
|
||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||
};
|
||||
for (const membership of memberships) {
|
||||
@@ -26,8 +26,7 @@ const setCompleteNotificationSettings = (
|
||||
for (const environment of project.environments) {
|
||||
for (const survey of environment.surveys) {
|
||||
newNotificationSettings.alert[survey.id] =
|
||||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
||||
?.responseFinished ||
|
||||
notificationSettings[survey.id]?.responseFinished ||
|
||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||
false; // check for legacy notification settings w/o "alerts" key
|
||||
}
|
||||
@@ -137,10 +136,7 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
return memberships;
|
||||
};
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) => {
|
||||
const Page = async (props) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -63,7 +63,7 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -198,54 +198,41 @@ export const EditProfileDetailsForm = ({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => {
|
||||
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
|
||||
|
||||
return (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{selectedLanguage ? (
|
||||
<>
|
||||
{selectedLanguage.label["en-US"]}
|
||||
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
|
||||
` (${selectedLanguage.label.native})`}
|
||||
</>
|
||||
) : (
|
||||
t("common.select")
|
||||
)}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{sortedAppLanguages.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label["en-US"]}
|
||||
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label["en-US"]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPasswordResetEnabled && (
|
||||
|
||||
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
|
||||
@@ -17,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
});
|
||||
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.schema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
@@ -55,36 +55,28 @@ const ZDeleteOrganizationAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"organization",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
}
|
||||
)
|
||||
);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
|
||||
}: DeleteOrganizationModalProps) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleInputChange = (e) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||
</div>
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
|
||||
const Layout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const SettingsTitle = ({ title }: { title: string }) => {
|
||||
export const SettingsTitle = ({ title }) => {
|
||||
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
|
||||
});
|
||||
|
||||
export const getResponsesAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponsesAction)
|
||||
.schema(ZGetResponsesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
|
||||
});
|
||||
|
||||
export const getSurveySummaryAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSurveySummaryAction)
|
||||
.schema(ZGetSurveySummaryAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
|
||||
});
|
||||
|
||||
export const getResponseCountAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponseCountAction)
|
||||
.schema(ZGetResponseCountAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.int().min(1).max(100),
|
||||
offset: z.int().nonnegative(),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
offset: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
.inputSchema(ZGetDisplaysWithContactAction)
|
||||
.schema(ZGetDisplaysWithContactAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
type Props = {
|
||||
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
|
||||
title: `${responseCount} Responses | ${survey?.name} Results`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -27,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
};
|
||||
};
|
||||
|
||||
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const SurveyLayout = async ({ children }) => {
|
||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
||||
};
|
||||
|
||||
|
||||
@@ -205,21 +205,16 @@ export const ResponseTable = ({
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
const downloadSelectedRows = async (responseIds: string[], format: string) => {
|
||||
const validFormat = format === "xlsx" ? "xlsx" : "csv";
|
||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
||||
try {
|
||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||
surveyId: survey.id,
|
||||
format: validFormat,
|
||||
format: format,
|
||||
filterCriteria: { responseIds },
|
||||
});
|
||||
|
||||
if (downloadResponse?.data) {
|
||||
downloadResponsesFile(
|
||||
downloadResponse.data.fileName,
|
||||
downloadResponse.data.fileContents,
|
||||
validFormat
|
||||
);
|
||||
downloadResponsesFile(downloadResponse.data.fileName, downloadResponse.data.fileContents, format);
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TFunction } from "i18next";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -46,7 +46,7 @@ const getElementColumnsData = (
|
||||
const ElementHeader = () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType as TSurveyElementTypeEnum]}</span>
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
|
||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
||||
|
||||
METADATA_FIELDS.forEach((label) => {
|
||||
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
|
||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
||||
|
||||
metadataColumns.push({
|
||||
accessorKey: "METADATA_" + label,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -39,7 +38,7 @@ describe("utils", () => {
|
||||
"environments.surveys.responses.source": "Source",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}) as unknown as TFunction;
|
||||
});
|
||||
|
||||
describe("getAddressFieldLabel", () => {
|
||||
test("returns correct label for addressLine1", () => {
|
||||
|
||||
@@ -80,24 +80,9 @@ export const COLUMNS_ICON_MAP = {
|
||||
const userAgentFields = ["browser", "os", "device"];
|
||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
||||
|
||||
export const getMetadataValue = (
|
||||
meta: TResponseMeta,
|
||||
label: (typeof METADATA_FIELDS)[number]
|
||||
): string | undefined => {
|
||||
switch (label) {
|
||||
case "browser":
|
||||
return meta.userAgent?.browser;
|
||||
case "os":
|
||||
return meta.userAgent?.os;
|
||||
case "device":
|
||||
return meta.userAgent?.device;
|
||||
case "action":
|
||||
return meta.action;
|
||||
case "country":
|
||||
return meta.country;
|
||||
case "source":
|
||||
return meta.source;
|
||||
case "url":
|
||||
return meta.url;
|
||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
||||
if (userAgentFields.includes(label)) {
|
||||
return meta.userAgent?.[label];
|
||||
}
|
||||
return meta[label];
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
});
|
||||
|
||||
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
|
||||
.schema(ZSendEmbedSurveyPreviewEmailAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||
@@ -69,7 +69,7 @@ const ZResetSurveyAction = z.object({
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
@@ -123,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
|
||||
});
|
||||
|
||||
export const getEmailHtmlAction = authenticatedActionClient
|
||||
.inputSchema(ZGetEmailHtmlAction)
|
||||
.schema(ZGetEmailHtmlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -152,7 +152,7 @@ const ZGeneratePersonalLinksAction = z.object({
|
||||
});
|
||||
|
||||
export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.inputSchema(ZGeneratePersonalLinksAction)
|
||||
.schema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
@@ -231,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
|
||||
});
|
||||
|
||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateSingleUseLinksAction)
|
||||
.schema(ZUpdateSingleUseLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.booked.count })}
|
||||
{elementSummary.booked.count}{" "}
|
||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
||||
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.skipped.count })}
|
||||
{elementSummary.skipped.count}{" "}
|
||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: summaryItem.count })}
|
||||
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
|
||||
{showResponses && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
|
||||
@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
{elementSummary.responseCount}{" "}
|
||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
if (label) {
|
||||
return label;
|
||||
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
||||
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
|
||||
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
)}>
|
||||
<button
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
|
||||
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
|
||||
elementSummary.type === "multipleChoiceMulti" ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{t("common.count_selections", { count: result.count })}
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
@@ -60,9 +60,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
},
|
||||
};
|
||||
|
||||
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
|
||||
group
|
||||
];
|
||||
const filter = filters[group];
|
||||
|
||||
if (filter) {
|
||||
setFilter(
|
||||
@@ -106,7 +104,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
@@ -125,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary[group]?.count })}
|
||||
{elementSummary[group]?.count}{" "}
|
||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -159,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
elementSummary.element.allowMulti ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{t("common.count_selections", { count: result.count })}
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: result.count })}
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
<div className="text flex justify-between px-2">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||
{elementSummary.dismissed.count}{" "}
|
||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
const formatTime = (ttc: number) => {
|
||||
const formatTime = (ttc) => {
|
||||
const seconds = ttc / 1000;
|
||||
let formattedValue;
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export const SummaryPage = ({
|
||||
setDisplays(data);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
toast.error(error);
|
||||
setDisplays([]);
|
||||
setHasMoreDisplays(false);
|
||||
} finally {
|
||||
|
||||
@@ -75,7 +75,17 @@ export const ShareSurveyModal = ({
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
const { email } = user;
|
||||
const { t } = useTranslation();
|
||||
const linkTabs = useMemo(() => {
|
||||
const linkTabs: {
|
||||
id: ShareViaType | ShareSettingsType;
|
||||
type: LinkTabsType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
}[] = useMemo(() => {
|
||||
const tabs = [
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
|
||||
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||
{projectCustomScripts}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
rows={8}
|
||||
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||
className={cn(
|
||||
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
{...field}
|
||||
disabled={isReadOnly}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error as Error, "Failed to generate QR code");
|
||||
logger.error("Failed to generate QR code:", error);
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
|
||||
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
|
||||
} catch (error) {
|
||||
logger.error(error as Error, "Failed to download QR code");
|
||||
logger.error("Failed to download QR code:", error);
|
||||
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
|
||||
@@ -4,10 +4,6 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
ShareSettingsType,
|
||||
ShareViaType,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
@@ -17,9 +13,9 @@ interface SuccessViewProps {
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
user: TUser;
|
||||
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
|
||||
handleViewChange: (view: "start" | "share") => void;
|
||||
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
|
||||
tabs: { id: string; label: string; icon: React.ElementType }[];
|
||||
handleViewChange: (view: string) => void;
|
||||
handleEmbedViewWithTab: (tabId: string) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
@@ -70,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.use_personal_links")}
|
||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
|
||||
_count: {
|
||||
quotaLinks: 0,
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getQuotasSummary(surveyId);
|
||||
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
|
||||
_count: {
|
||||
quotaLinks: 0,
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getQuotasSummary(surveyId);
|
||||
|
||||
@@ -662,23 +662,17 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
||||
const item1 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||
);
|
||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
||||
expect(item1.count).toBe(2);
|
||||
expect(item1.avgRanking).toBe(1.5);
|
||||
|
||||
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
||||
const item2 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||
);
|
||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
||||
expect(item2.count).toBe(2);
|
||||
expect(item2.avgRanking).toBe(1.5);
|
||||
|
||||
// Item 3 is in position 3 twice, so avg ranking should be 3
|
||||
const item3 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||
);
|
||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
||||
expect(item3.count).toBe(2);
|
||||
expect(item3.avgRanking).toBe(3);
|
||||
});
|
||||
@@ -753,23 +747,17 @@ describe("getQuestionSummary", () => {
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
|
||||
// Item 1 is in position 2, so avg ranking should be 2
|
||||
const item1 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||
);
|
||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
||||
expect(item1.count).toBe(1);
|
||||
expect(item1.avgRanking).toBe(2);
|
||||
|
||||
// Item 2 is in position 1, so avg ranking should be 1
|
||||
const item2 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||
);
|
||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
||||
expect(item2.count).toBe(1);
|
||||
expect(item2.avgRanking).toBe(1);
|
||||
|
||||
// Item 3 is in position 3, so avg ranking should be 3
|
||||
const item3 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||
);
|
||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
||||
expect(item3.count).toBe(1);
|
||||
expect(item3.avgRanking).toBe(3);
|
||||
});
|
||||
@@ -842,12 +830,10 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// All items should have count 0 and avgRanking 0
|
||||
(summary[0] as any).choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
}
|
||||
);
|
||||
(summary[0] as any).choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles ranking question with non-array answers", async () => {
|
||||
@@ -908,12 +894,10 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
|
||||
(summary[0] as any).choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
}
|
||||
);
|
||||
(summary[0] as any).choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles ranking question with values not in choices", async () => {
|
||||
@@ -974,23 +958,17 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// Item 1 is in position 1, so avg ranking should be 1
|
||||
const item1 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||
);
|
||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
||||
expect(item1.count).toBe(1);
|
||||
expect(item1.avgRanking).toBe(1);
|
||||
|
||||
// Item 2 was not ranked, so should have count 0 and avgRanking 0
|
||||
const item2 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||
);
|
||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
||||
expect(item2.count).toBe(0);
|
||||
expect(item2.avgRanking).toBe(0);
|
||||
|
||||
// Item 3 is in position 3, so avg ranking should be 3
|
||||
const item3 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||
);
|
||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
||||
expect(item3.count).toBe(1);
|
||||
expect(item3.avgRanking).toBe(3);
|
||||
});
|
||||
@@ -1008,11 +986,7 @@ describe("getSurveySummary", () => {
|
||||
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
|
||||
// which is used by the actual implementation of getResponsesForSummary.
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||
mockResponses.map((r: Record<string, unknown>) => ({
|
||||
...r,
|
||||
contactId: null,
|
||||
personAttributes: {},
|
||||
})) as any
|
||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
||||
);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
|
||||
|
||||
@@ -1046,8 +1020,8 @@ describe("getSurveySummary", () => {
|
||||
test("handles filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = { finished: true };
|
||||
const finishedResponses = mockResponses
|
||||
.filter((r: Record<string, unknown>) => r.finished)
|
||||
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||
.filter((r) => r.finished)
|
||||
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
@@ -1069,11 +1043,7 @@ describe("getResponsesForSummary", () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||
mockResponses.map((r: Record<string, unknown>) => ({
|
||||
...r,
|
||||
contactId: null,
|
||||
personAttributes: {},
|
||||
})) as any
|
||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
||||
);
|
||||
// React cache is already mocked globally - no need to mock it again
|
||||
});
|
||||
@@ -1872,63 +1842,23 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(2);
|
||||
|
||||
// Verify Speed row
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
speedRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
||||
|
||||
// Verify Quality row
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
expect(qualityRow.totalResponsesForRow).toBe(2);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Excellent"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Good"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
||||
|
||||
// Verify Price row
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
expect(priceRow.totalResponsesForRow).toBe(2);
|
||||
expect(
|
||||
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor")
|
||||
.percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
priceRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
||||
});
|
||||
|
||||
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
|
||||
@@ -2019,48 +1949,19 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
|
||||
// Verify Speed row with localized values mapped to default language
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
|
||||
// Verify Quality row
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
expect(qualityRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Excellent"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
|
||||
|
||||
// Verify Price row
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
expect(priceRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
priceRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
|
||||
@@ -2154,18 +2055,12 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
|
||||
|
||||
// All rows should have zero responses for all columns
|
||||
summary[0].data.forEach(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
}
|
||||
);
|
||||
summary[0].data.forEach((row) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
|
||||
@@ -2252,59 +2147,22 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(2);
|
||||
|
||||
// Verify Speed row - both responses provided data
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
speedRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
||||
|
||||
// Verify Quality row - only one response provided data
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
expect(qualityRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Good"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
|
||||
// Verify Price row - both responses provided data
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
expect(priceRow.totalResponsesForRow).toBe(2);
|
||||
|
||||
// ExtraRow should not appear in the summary
|
||||
expect(
|
||||
summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "ExtraRow"
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
|
||||
@@ -2363,18 +2221,12 @@ describe("Matrix question type tests", () => {
|
||||
// All rows should have proper structure but zero counts
|
||||
expect(summary[0].data).toHaveLength(2); // 2 rows
|
||||
|
||||
summary[0].data.forEach(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => {
|
||||
expect(row.columnPercentages).toHaveLength(2); // 2 columns
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
expect(row.columnPercentages[0].percentage).toBe(0);
|
||||
expect(row.columnPercentages[1].percentage).toBe(0);
|
||||
}
|
||||
);
|
||||
summary[0].data.forEach((row) => {
|
||||
expect(row.columnPercentages).toHaveLength(2); // 2 columns
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
expect(row.columnPercentages[0].percentage).toBe(0);
|
||||
expect(row.columnPercentages[1].percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
|
||||
@@ -2444,46 +2296,21 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
|
||||
// Speed row should have a valid response
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
|
||||
// Quality row should have no valid responses
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||
qualityRow.columnPercentages.forEach((col) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
|
||||
// Price row should have a valid response
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
expect(priceRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
priceRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
|
||||
@@ -2554,48 +2381,17 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].data).toHaveLength(2); // 2 rows
|
||||
|
||||
// Speed row should have a valid response
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
|
||||
// Quality row should have no responses
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||
|
||||
// Invalid rows should not appear in the summary
|
||||
expect(
|
||||
summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "InvalidRow"
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "AnotherInvalidRow"
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
|
||||
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
|
||||
@@ -2697,27 +2493,12 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(2);
|
||||
|
||||
// Speed row should have both responses
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
|
||||
// Quality row should have no responses
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||
});
|
||||
|
||||
@@ -2776,18 +2557,12 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
|
||||
|
||||
// Both rows should have zero responses
|
||||
summary[0].data.forEach(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
}
|
||||
);
|
||||
summary[0].data.forEach((row) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3219,33 +2994,23 @@ describe("Rating question type tests", () => {
|
||||
expect(summary[0].average).toBe(4.25);
|
||||
|
||||
// Verify each rating option count and percentage
|
||||
const rating5 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
|
||||
);
|
||||
const rating5 = summary[0].choices.find((c) => c.rating === 5);
|
||||
expect(rating5.count).toBe(2);
|
||||
expect(rating5.percentage).toBe(50); // 2/4 * 100
|
||||
|
||||
const rating4 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
|
||||
);
|
||||
const rating4 = summary[0].choices.find((c) => c.rating === 4);
|
||||
expect(rating4.count).toBe(1);
|
||||
expect(rating4.percentage).toBe(25); // 1/4 * 100
|
||||
|
||||
const rating3 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
|
||||
);
|
||||
const rating3 = summary[0].choices.find((c) => c.rating === 3);
|
||||
expect(rating3.count).toBe(1);
|
||||
expect(rating3.percentage).toBe(25); // 1/4 * 100
|
||||
|
||||
const rating2 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
|
||||
);
|
||||
const rating2 = summary[0].choices.find((c) => c.rating === 2);
|
||||
expect(rating2.count).toBe(0);
|
||||
expect(rating2.percentage).toBe(0);
|
||||
|
||||
const rating1 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
|
||||
);
|
||||
const rating1 = summary[0].choices.find((c) => c.rating === 1);
|
||||
expect(rating1.count).toBe(0);
|
||||
expect(rating1.percentage).toBe(0);
|
||||
|
||||
@@ -3389,12 +3154,10 @@ describe("Rating question type tests", () => {
|
||||
expect(summary[0].average).toBe(0);
|
||||
|
||||
// Verify all ratings have 0 count and percentage
|
||||
summary[0].choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
}
|
||||
);
|
||||
summary[0].choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
});
|
||||
|
||||
// Verify dismissed is 0
|
||||
expect(summary[0].dismissed.count).toBe(0);
|
||||
@@ -3469,21 +3232,15 @@ describe("PictureSelection question type tests", () => {
|
||||
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
|
||||
|
||||
// Check individual choice counts
|
||||
const img1 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
|
||||
);
|
||||
const img1 = summary[0].choices.find((c) => c.id === "img1");
|
||||
expect(img1.count).toBe(1);
|
||||
expect(img1.percentage).toBe(50);
|
||||
|
||||
const img2 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
|
||||
);
|
||||
const img2 = summary[0].choices.find((c) => c.id === "img2");
|
||||
expect(img2.count).toBe(1);
|
||||
expect(img2.percentage).toBe(50);
|
||||
|
||||
const img3 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
|
||||
);
|
||||
const img3 = summary[0].choices.find((c) => c.id === "img3");
|
||||
expect(img3.count).toBe(1);
|
||||
expect(img3.percentage).toBe(50);
|
||||
});
|
||||
@@ -3554,12 +3311,10 @@ describe("PictureSelection question type tests", () => {
|
||||
expect(summary[0].selectionCount).toBe(0);
|
||||
|
||||
// All choices should have zero count
|
||||
summary[0].choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
}
|
||||
);
|
||||
summary[0].choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
|
||||
@@ -3618,23 +3373,17 @@ describe("PictureSelection question type tests", () => {
|
||||
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
|
||||
|
||||
// img1 should be counted
|
||||
const img1 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
|
||||
);
|
||||
const img1 = summary[0].choices.find((c) => c.id === "img1");
|
||||
expect(img1.count).toBe(1);
|
||||
expect(img1.percentage).toBe(100);
|
||||
|
||||
// img2 should not be counted
|
||||
const img2 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
|
||||
);
|
||||
const img2 = summary[0].choices.find((c) => c.id === "img2");
|
||||
expect(img2.count).toBe(0);
|
||||
expect(img2.percentage).toBe(0);
|
||||
|
||||
// Invalid ID should not appear in choices
|
||||
expect(
|
||||
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
|
||||
).toBeUndefined();
|
||||
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyElementSummaryAddress,
|
||||
@@ -289,10 +293,7 @@ const checkForI18n = (
|
||||
) => {
|
||||
const element = elements.find((element) => element.id === id);
|
||||
|
||||
if (
|
||||
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
element?.type === TSurveyElementTypeEnum.Ranking
|
||||
) {
|
||||
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
|
||||
// Initialize an array to hold the choice values
|
||||
let choiceValues = [] as string[];
|
||||
|
||||
@@ -317,9 +318,13 @@ const checkForI18n = (
|
||||
}
|
||||
|
||||
// Return the localized value of the choice fo multiSelect single element
|
||||
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
|
||||
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
|
||||
if (element && "choices" in element) {
|
||||
const choice = element.choices?.find(
|
||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
||||
);
|
||||
return choice && "label" in choice
|
||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
||||
: responseData[id];
|
||||
}
|
||||
|
||||
return responseData[id];
|
||||
@@ -827,19 +832,13 @@ export const getElementSummary = async (
|
||||
let totalResponseCount = 0;
|
||||
|
||||
// Initialize count object
|
||||
const countMap: Record<string, Record<string, number>> = rows.reduce(
|
||||
(acc: Record<string, Record<string, number>>, row) => {
|
||||
acc[row] = columns.reduce(
|
||||
(colAcc: Record<string, number>, col) => {
|
||||
colAcc[col] = 0;
|
||||
return colAcc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Record<string, number>>
|
||||
);
|
||||
const countMap: Record<string, string> = rows.reduce((acc, row) => {
|
||||
acc[row] = columns.reduce((colAcc, col) => {
|
||||
colAcc[col] = 0;
|
||||
return colAcc;
|
||||
}, {});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
responses.forEach((response) => {
|
||||
const selectedResponses = response.data[element.id] as Record<string, string>;
|
||||
@@ -1096,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.cuid2().optional()]
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
|
||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
@@ -27,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
||||
});
|
||||
|
||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||
.schema(ZGetResponsesDownloadUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -57,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
|
||||
});
|
||||
|
||||
export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSurveyFilterDataAction)
|
||||
.schema(ZGetSurveyFilterDataAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
|
||||
@@ -120,7 +121,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
@@ -154,6 +155,10 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
if (parsedInput.languages?.length) {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
}
|
||||
|
||||
// Context for audit log
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
|
||||
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => {
|
||||
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (let key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - "));
|
||||
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
|
||||
} else {
|
||||
keys.push(parentKey + key);
|
||||
}
|
||||
|
||||
@@ -113,9 +113,7 @@ const elementIcons = {
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
|
||||
type
|
||||
];
|
||||
const IconComponent = elementIcons[type];
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
@@ -194,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
setFilterValue({ ...filterValue });
|
||||
};
|
||||
const handleRemoveMultiSelect = (value: string[], index: number) => {
|
||||
const handleRemoveMultiSelect = (value: string[], index) => {
|
||||
filterValue.filter[index] = {
|
||||
...filterValue.filter[index],
|
||||
filterType: {
|
||||
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
|
||||
};
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
const FORMBRICKS_HOST = "https://app.formbricks.com";
|
||||
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
|
||||
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
|
||||
|
||||
interface WorkflowsPageProps {
|
||||
userEmail: string;
|
||||
organizationName: string;
|
||||
billingPlan: string;
|
||||
}
|
||||
|
||||
type Step = "prompt" | "followup" | "thankyou";
|
||||
|
||||
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("prompt");
|
||||
const [promptValue, setPromptValue] = useState("");
|
||||
const [detailsValue, setDetailsValue] = useState("");
|
||||
const [responseId, setResponseId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleGenerateWorkflow = async () => {
|
||||
if (promptValue.trim().length < 100 || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
surveyId: SURVEY_ID,
|
||||
finished: false,
|
||||
data: {
|
||||
workflow: promptValue.trim(),
|
||||
useremail: userEmail,
|
||||
orgname: organizationName,
|
||||
billingplan: billingPlan,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setResponseId(json.data?.id ?? null);
|
||||
}
|
||||
|
||||
setStep("followup");
|
||||
} catch {
|
||||
setStep("followup");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitFeedback = async () => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (responseId) {
|
||||
try {
|
||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
data: {
|
||||
details: detailsValue.trim(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
setStep("thankyou");
|
||||
};
|
||||
|
||||
const handleSkipFeedback = async () => {
|
||||
if (!responseId) {
|
||||
setStep("thankyou");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
|
||||
setStep("thankyou");
|
||||
};
|
||||
|
||||
if (step === "prompt") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
|
||||
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={promptValue}
|
||||
onChange={(e) => setPromptValue(e.target.value)}
|
||||
placeholder={t("workflows.placeholder")}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
handleGenerateWorkflow();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
|
||||
{promptValue.trim().length} / 100
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleGenerateWorkflow}
|
||||
disabled={promptValue.trim().length < 100 || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
size="lg">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("workflows.generate_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "followup") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||
<Sparkles className="h-6 w-6 text-brand-dark" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
|
||||
{t("workflows.coming_soon_title")}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-md text-base text-slate-500">
|
||||
{t("workflows.coming_soon_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<label className="text-md mb-2 block font-medium text-slate-700">
|
||||
{t("workflows.follow_up_label")}
|
||||
</label>
|
||||
<textarea
|
||||
value={detailsValue}
|
||||
onChange={(e) => setDetailsValue(e.target.value)}
|
||||
placeholder={t("workflows.follow_up_placeholder")}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
|
||||
{t("common.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitFeedback}
|
||||
disabled={!detailsValue.trim() || isSubmitting}
|
||||
loading={isSubmitting}>
|
||||
{t("workflows.submit_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
|
||||
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { WorkflowsPage } from "./components/workflows-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workflows",
|
||||
};
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowsPage
|
||||
userEmail={user.email}
|
||||
organizationName={organization.name}
|
||||
billingPlan={organization.billing.plan}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -21,7 +21,7 @@ const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
});
|
||||
|
||||
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateOrUpdateIntegrationAction)
|
||||
.schema(ZCreateOrUpdateIntegrationAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"createdUpdated",
|
||||
@@ -67,7 +67,7 @@ const ZDeleteIntegrationAction = z.object({
|
||||
integrationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action(
|
||||
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"integration",
|
||||
|
||||
@@ -284,7 +284,7 @@ export const AddIntegrationModal = ({
|
||||
}
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
|
||||
toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,7 +322,7 @@ export const AddIntegrationModal = ({
|
||||
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
|
||||
toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||
|
||||
@@ -17,7 +17,7 @@ const ZValidateGoogleSheetsConnectionAction = z.object({
|
||||
});
|
||||
|
||||
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
.inputSchema(ZValidateGoogleSheetsConnectionAction)
|
||||
.schema(ZValidateGoogleSheetsConnectionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -51,7 +51,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
|
||||
});
|
||||
|
||||
export const getSpreadsheetNameByIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSpreadsheetNameByIdAction)
|
||||
.schema(ZGetSpreadsheetNameByIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -195,7 +195,7 @@ export const AddIntegrationModal = ({
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsLinkingSheet(false);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export const AddIntegrationModal = ({
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||
|
||||
@@ -234,7 +234,7 @@ export const AddIntegrationModal = ({
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsLinkingDatabase(false);
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const MappingErrorMessage = ({
|
||||
question_label: element.label,
|
||||
col_name: col.name,
|
||||
col_type: col.type,
|
||||
mapped_type: (TYPE_MAPPING as Record<string, string[]>)[element.id].join(" ,"),
|
||||
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
@@ -135,7 +135,7 @@ export const MappingRow = ({
|
||||
return copy;
|
||||
}
|
||||
|
||||
const isValidColType = (TYPE_MAPPING as Record<string, string[]>)[item.type]?.includes(col.type);
|
||||
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
|
||||
if (!isValidColType) {
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
@@ -166,7 +166,7 @@ export const MappingRow = ({
|
||||
return copy;
|
||||
}
|
||||
|
||||
const isValidElemType = (TYPE_MAPPING as Record<string, string[]>)[elem.type]?.includes(item.type);
|
||||
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
||||
if (!isValidElemType) {
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
|
||||
@@ -36,7 +36,7 @@ export const NotionWrapper = ({
|
||||
}: NotionWrapperProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
notionIntegration ? !!notionIntegration.config.key?.bot_id : false
|
||||
notionIntegration ? notionIntegration.config.key?.bot_id : false
|
||||
);
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||
(TIntegrationNotionConfigData & { index: number }) | null
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
{t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const enabled = !!(
|
||||
|
||||
@@ -27,7 +27,7 @@ const getStatusText = (count: number, t: TFunction, type: string) => {
|
||||
return `${count} ${type}s`;
|
||||
};
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
|
||||
});
|
||||
|
||||
export const getSlackChannelsAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSlackChannelsAction)
|
||||
.schema(ZGetSlackChannelsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -161,7 +161,7 @@ export const AddChannelMappingModal = ({
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsLinkingChannel(false);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export const AddChannelMappingModal = ({
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const SlackWrapper = ({
|
||||
webAppUrl,
|
||||
locale,
|
||||
}: SlackWrapperProps) => {
|
||||
const [isConnected, setIsConnected] = useState(slackIntegration ? !!slackIntegration.config?.key : false);
|
||||
const [isConnected, setIsConnected] = useState(slackIntegration ? slackIntegration.config?.key : false);
|
||||
const [slackChannels, setSlackChannels] = useState<TIntegrationItem[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
|
||||
import { LanguagesLoading } from "@/modules/ee/languages/loading";
|
||||
|
||||
export default LanguagesLoading;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
|
||||
import { LanguagesPage } from "@/modules/ee/languages/page";
|
||||
|
||||
export default LanguagesPage;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
|
||||
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const AppLayout = async ({ children }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
|
||||
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
|
||||
@@ -169,9 +169,6 @@ export const mockSurvey: TSurvey = {
|
||||
segment: null,
|
||||
followUps: mockFollowUps,
|
||||
metadata: {},
|
||||
blocks: [],
|
||||
isCaptureIpEnabled: false,
|
||||
slug: null,
|
||||
};
|
||||
|
||||
export const mockContactQuestion: TSurveyContactInfoQuestion = {
|
||||
|
||||
@@ -156,7 +156,7 @@ const handleAirtableIntegration = async (
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -197,7 +197,7 @@ const handleGoogleSheetsIntegration = async (
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -239,7 +239,7 @@ const handleSlackIntegration = async (
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -349,7 +349,7 @@ const handleNotionIntegration = async (
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -374,8 +374,8 @@ const buildNotionPayloadProperties = (
|
||||
const pictureElement = surveyElements.find((el) => el.id === resp);
|
||||
|
||||
responses[resp] = (pictureElement as any)?.choices
|
||||
.filter((choice: { id: string; imageUrl: string }) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice: { id: string; imageUrl: string }) => resolveStorageUrlAuto(choice.imageUrl));
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
@@ -136,17 +135,13 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
return validateWebhookUrl(webhook.url)
|
||||
.then(() =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
return fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user