mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-25 03:10:22 -05:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ea39bcf2 | |||
| be5beaeed7 | |||
| bc56f99fd8 | |||
| 0f38627627 | |||
| a878bdff42 | |||
| d757e12c76 | |||
| 629febb2f7 | |||
| 40b93cc834 | |||
| f41d2c14f1 | |||
| af51414b03 | |||
| a9e39dd4ab | |||
| c8b0bb2225 | |||
| f6aa27ba8c | |||
| 82765f7dd7 | |||
| d5bbafcf90 | |||
| db87a588b5 | |||
| c834587c8d | |||
| ef18aacfa2 | |||
| 025a766c57 | |||
| f476db3128 | |||
| 37023275ca | |||
| 9266f64588 | |||
| 032066194b | |||
| 0bef023302 | |||
| aa83ee336c | |||
| 4357f497a1 | |||
| 526c17af23 | |||
| a0ddadebad | |||
| bc0d04f5e8 | |||
| f0967c2e23 | |||
| 13c9677edd | |||
| c0bf2ab7cc | |||
| 65d0f4ac0e | |||
| 655c0b5e47 |
@@ -53,7 +53,7 @@ function {QuestionType}({
|
||||
}: {QuestionType}Props): React.JSX.Element {
|
||||
// Ensure value is always the correct type (handle undefined/null)
|
||||
const currentValue = value ?? {defaultValue};
|
||||
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
@@ -63,11 +63,11 @@ function {QuestionType}({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { App } from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
+10
-9
@@ -120,30 +120,31 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
|
||||
<button type="button" className="flex w-full items-center gap-3">
|
||||
<span className={switcherIconClasses}>
|
||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
<Building2Icon className="size-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||
</div>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
{isPending && <Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />}
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
<Building2Icon className="mr-2 inline size-4" strokeWidth={1.5} />
|
||||
{t("common.change_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && organizationLoadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
@@ -171,7 +172,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
@@ -197,7 +198,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -210,7 +211,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className="flex w-full items-center">
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
<link.icon className="mr-2 size-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
@@ -225,7 +226,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
icon={<LogOutIcon className="mr-2 size-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
isMembershipPending={isMembershipPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
|
||||
<Header
|
||||
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
<XIcon className="size-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ const Page = async (props: ModePageProps) => {
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
|
||||
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{workspaces.length >= 1 && (
|
||||
@@ -59,7 +59,7 @@ const Page = async (props: ModePageProps) => {
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
<XIcon className="size-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+3
-3
@@ -125,8 +125,8 @@ export const WorkspaceSettings = ({
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<div className="mt-6 flex w-5/6 gap-x-10 lg:w-2/3 2xl:w-1/2">
|
||||
<div className="flex w-1/2 flex-col gap-y-4">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
|
||||
<FormField
|
||||
@@ -224,7 +224,7 @@ export const WorkspaceSettings = ({
|
||||
</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 w-1/2 flex-col items-center justify-center gap-y-2 rounded-lg border bg-slate-200 p-6 shadow">
|
||||
{logoUrl && (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
|
||||
<Header
|
||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||
@@ -115,7 +115,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
<XIcon className="size-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContain
|
||||
description={option.description}
|
||||
loading={option.isLoading || false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={1} />
|
||||
<Icon className="size-16 text-slate-600" strokeWidth={1} />
|
||||
{option.iconText && (
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
|
||||
+6
-6
@@ -43,9 +43,9 @@ export const ConnectWithFormbricks = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-5/6 flex-col items-center space-y-10 lg:w-2/3 2xl:w-1/2">
|
||||
<div className="flex w-full space-x-10">
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<div className="mt-6 flex w-5/6 flex-col items-center gap-y-10 lg:w-2/3 2xl:w-1/2">
|
||||
<div className="flex w-full gap-x-10">
|
||||
<div className="flex w-1/2 flex-col gap-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
workspaceId={workspaceId}
|
||||
publicDomain={publicDomain}
|
||||
@@ -66,10 +66,10 @@ export const ConnectWithFormbricks = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||
<span className="relative flex h-10 w-10">
|
||||
<div className="flex animate-pulse flex-col items-center gap-y-4">
|
||||
<span className="relative flex size-10">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
<span className="relative inline-flex size-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
{t("workspace.connect.waiting_for_your_signal")}
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ export const OnboardingSetupInstructions = ({
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<div className="mt-4 flex justify-between gap-x-2">
|
||||
<Button
|
||||
id="onboarding-inapp-connect-copy-code"
|
||||
variant={appSetupCompleted ? "secondary" : "default"}
|
||||
|
||||
@@ -45,7 +45,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/workspaces/${params.workspaceId}`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
<XIcon className="size-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
|
||||
<Header title={t("workspace.xm-templates.headline")} />
|
||||
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
|
||||
{workspaces.length >= 2 && (
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
<XIcon className="size-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -238,7 +238,10 @@ export const MainNavigation = ({
|
||||
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{error}</p>
|
||||
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
@@ -571,285 +574,275 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{!isSettingsMode && (
|
||||
<>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed &&
|
||||
isOwnerOrManager &&
|
||||
latestVersion &&
|
||||
!isFormbricksCloud &&
|
||||
!isDevelopment && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
|
||||
<p className="flex items-center justify-center gap-x-2 text-xs">
|
||||
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
|
||||
{t("common.new_version_available", { version: latestVersion })}
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed &&
|
||||
isFormbricksCloud &&
|
||||
trialDaysRemaining !== null &&
|
||||
(newTrialBannerVariant === "new-trial-banner" ? (
|
||||
<TrialBannerNew
|
||||
trialDaysRemaining={trialDaysRemaining}
|
||||
planName={organization.billing.stripe?.plan ?? "pro"}
|
||||
responseCount={responseCount}
|
||||
responseLimit={organization.billing.limits.monthly.responses}
|
||||
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
|
||||
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
{!isSettingsMode && (
|
||||
<>
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="workspaceDropdownTrigger"
|
||||
className={switcherTriggerClasses}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{workspace.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||
)}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_workspace")}
|
||||
</div>
|
||||
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingWorkspaces &&
|
||||
!isInitialWorkspacesLoading &&
|
||||
workspaceLoadError &&
|
||||
renderSwitcherError(
|
||||
workspaceLoadError,
|
||||
() => {
|
||||
setWorkspaceLoadError(null);
|
||||
setWorkspaces([]);
|
||||
},
|
||||
t("common.try_again")
|
||||
)}
|
||||
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{workspaces.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === workspace.id}
|
||||
onClick={() => handleWorkspaceChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
{proj.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleWorkspaceCreate}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() =>
|
||||
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
|
||||
}
|
||||
className="cursor-pointer">
|
||||
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="organizationDropdownTriggerSidebar"
|
||||
className={switcherTriggerClasses}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||
)}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations &&
|
||||
organizationLoadError &&
|
||||
renderSwitcherError(
|
||||
organizationLoadError,
|
||||
() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
},
|
||||
t("common.try_again")
|
||||
)}
|
||||
{!isLoadingOrganizations && !organizationLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === organization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() =>
|
||||
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
|
||||
}
|
||||
className="cursor-pointer">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
{!isSettingsMode && (
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
className="m-2 flex items-center gap-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
|
||||
<p className="flex items-center justify-center gap-x-2 text-xs">
|
||||
<RocketIcon strokeWidth={1.5} className="mx-1 size-6 text-slate-900" />
|
||||
{t("common.new_version_available", { version: latestVersion })}
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed &&
|
||||
isFormbricksCloud &&
|
||||
trialDaysRemaining !== null &&
|
||||
(newTrialBannerVariant === "new-trial-banner" ? (
|
||||
<TrialBannerNew
|
||||
trialDaysRemaining={trialDaysRemaining}
|
||||
planName={organization.billing.stripe?.plan ?? "pro"}
|
||||
responseCount={responseCount}
|
||||
responseLimit={organization.billing.limits.monthly.responses}
|
||||
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
|
||||
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="workspaceDropdownTrigger"
|
||||
className={switcherTriggerClasses}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<FoldersIcon className="size-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{workspace.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||
)}
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FoldersIcon className="mr-2 inline size-4" strokeWidth={1.5} />
|
||||
{t("common.change_workspace")}
|
||||
</div>
|
||||
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingWorkspaces &&
|
||||
!isInitialWorkspacesLoading &&
|
||||
workspaceLoadError &&
|
||||
renderSwitcherError(
|
||||
workspaceLoadError,
|
||||
() => {
|
||||
setWorkspaceLoadError(null);
|
||||
setWorkspaces([]);
|
||||
},
|
||||
t("common.try_again")
|
||||
)}
|
||||
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{workspaces.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === workspace.id}
|
||||
onClick={() => handleWorkspaceChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
{proj.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleWorkspaceCreate}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() =>
|
||||
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
|
||||
}
|
||||
className="cursor-pointer">
|
||||
<Cog className="mr-2 size-4" strokeWidth={1.5} />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: loginUrl,
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: loginUrl,
|
||||
clearWorkspaceId: true,
|
||||
});
|
||||
router.push(route?.url || loginUrl);
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="organizationDropdownTriggerSidebar"
|
||||
className={switcherTriggerClasses}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<Building2Icon className="size-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||
)}
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline size-4" strokeWidth={1.5} />
|
||||
{t("common.change_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations &&
|
||||
organizationLoadError &&
|
||||
renderSwitcherError(
|
||||
organizationLoadError,
|
||||
() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
},
|
||||
t("common.try_again")
|
||||
)}
|
||||
{!isLoadingOrganizations && !organizationLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === organization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() =>
|
||||
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
|
||||
}
|
||||
className="cursor-pointer">
|
||||
<SettingsIcon className="mr-2 size-4" strokeWidth={1.5} />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 size-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: loginUrl,
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: loginUrl,
|
||||
clearWorkspaceId: true,
|
||||
});
|
||||
router.push(route?.url || loginUrl);
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 size-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{openWorkspaceLimitModal && (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const NavbarLoading = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between space-x-4 px-4 py-2">
|
||||
<div className="flex justify-between gap-x-4 px-4 py-2">
|
||||
<div className="flex">
|
||||
<div className="mx-2 h-8 w-8 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="mx-2 size-8 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
|
||||
@@ -11,7 +11,7 @@ export const NavbarLoading = () => {
|
||||
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mx-2 h-8 w-8 animate-pulse rounded-full bg-slate-200"></div>
|
||||
<div className="mx-2 size-8 animate-pulse rounded-full bg-slate-200"></div>
|
||||
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,12 +195,12 @@ const SectionHeader = ({
|
||||
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
|
||||
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
|
||||
<span className="truncate">{switcherName}</span>
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-[300px]">
|
||||
{isLoadingSwitcher ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuGroup className="overflow-y-auto">
|
||||
@@ -374,6 +374,7 @@ export const SettingsSidebarContent = ({
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
@@ -408,7 +409,7 @@ export const SettingsSidebarContent = ({
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={<FoldersIcon className="h-4 w-4" />}
|
||||
icon={<FoldersIcon className="size-4" />}
|
||||
label={t("common.workspace")}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
@@ -424,7 +425,7 @@ export const SettingsSidebarContent = ({
|
||||
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={<Building2Icon className="h-4 w-4" />}
|
||||
icon={<Building2Icon className="size-4" />}
|
||||
label={t("common.organization")}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
@@ -440,7 +441,7 @@ export const SettingsSidebarContent = ({
|
||||
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={<UserCircleIcon className="h-4 w-4" />}
|
||||
icon={<UserCircleIcon className="size-4" />}
|
||||
label={t("common.account")}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
|
||||
@@ -124,13 +124,13 @@ export const OrganizationBreadcrumb = ({
|
||||
id="organizationDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<Building2Icon className="size-3" strokeWidth={1.5} />
|
||||
<span>{organizationName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isPending && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<ChevronDownIcon className="size-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<ChevronRightIcon className="size-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -138,18 +138,19 @@ export const OrganizationBreadcrumb = ({
|
||||
{showOrganizationDropdown && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline h-4 w-4" />
|
||||
<Building2Icon className="mr-2 inline size-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setOrganizations([]);
|
||||
@@ -177,7 +178,7 @@ export const OrganizationBreadcrumb = ({
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
<PlusIcon className="ml-2 size-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
@@ -190,7 +191,7 @@ export const OrganizationBreadcrumb = ({
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
|
||||
className="cursor-pointer">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<SettingsIcon className="mr-2 size-4" />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</>
|
||||
|
||||
@@ -154,31 +154,32 @@ export const WorkspaceBreadcrumb = ({
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger className="flex cursor-pointer items-center gap-1 outline-none" asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<FoldersIcon className="size-3" strokeWidth={1.5} />
|
||||
<span>{workspaceName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isPending && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<ChevronRightIcon className="size-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<ChevronDownIcon className="size-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
<FoldersIcon className="mr-2 inline size-4" strokeWidth={1.5} />
|
||||
{t("common.choose_workspace")}
|
||||
</div>
|
||||
{isLoadingWorkspaces && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingWorkspaces && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setWorkspaces([]);
|
||||
@@ -211,7 +212,7 @@ export const WorkspaceBreadcrumb = ({
|
||||
aria-disabled="true"
|
||||
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
@@ -225,7 +226,7 @@ export const WorkspaceBreadcrumb = ({
|
||||
onClick={handleAddWorkspace}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
@@ -236,7 +237,7 @@ export const WorkspaceBreadcrumb = ({
|
||||
handleWorkspaceSettingsNavigation(`${workspaceBasePath}/settings/workspace/general`)
|
||||
}
|
||||
className="cursor-pointer">
|
||||
<CogIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
<CogIcon className="mr-2 size-4" strokeWidth={1.5} />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const AccountSettingsLayout = async (
|
||||
props: Readonly<{
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}>
|
||||
) => {
|
||||
await props.params;
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const AccountSettingsLayout = async (props: Readonly<{
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
|
||||
+4
-4
@@ -28,8 +28,8 @@ export const EditAlerts = ({
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
<div key={membership.organization.id}>
|
||||
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
|
||||
<div className="col-span-3 flex items-center space-x-3">
|
||||
<div className="mb-5 grid grid-cols-6 items-center gap-x-3">
|
||||
<div className="col-span-3 flex items-center gap-x-3">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
|
||||
<p className="text-sm font-medium text-slate-800">{membership.organization.name}</p>
|
||||
@@ -54,9 +54,9 @@ export const EditAlerts = ({
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
|
||||
<div className="col-span-1 flex cursor-default items-center justify-center gap-x-2">
|
||||
<span>{t("workspace.settings.notifications.every_response")}</span>
|
||||
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
||||
<HelpCircleIcon className="size-4 flex-shrink-0 text-slate-500" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ export const IntegrationsTip = () => {
|
||||
const { workspace } = useWorkspace();
|
||||
return (
|
||||
<div>
|
||||
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
|
||||
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
|
||||
<div className="flex max-w-4xl items-center gap-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:gap-y-0 md:text-base">
|
||||
<SlackIcon className="mr-3 size-4 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
<a
|
||||
|
||||
@@ -6,7 +6,6 @@ import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/accoun
|
||||
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/IntegrationsTip";
|
||||
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/types";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -128,13 +127,7 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
return memberships;
|
||||
};
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export const AccountSecurity = ({ user }: AccountSecurityProps) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
checked={user.twoFactorEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
|
||||
+1
-1
@@ -225,7 +225,7 @@ export const EditProfileDetailsForm = ({
|
||||
) : (
|
||||
t("common.select")
|
||||
)}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
<ChevronDownIcon className="size-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
+2
-2
@@ -112,12 +112,12 @@ export const EnterpriseLicenseStatus = ({
|
||||
className="shrink-0">
|
||||
{isRechecking ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
<RotateCcwIcon className="mr-2 size-4 animate-spin" />
|
||||
{t("workspace.settings.enterprise.rechecking")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<RotateCcwIcon className="mr-2 size-4" />
|
||||
{t("workspace.settings.enterprise.recheck_license")}
|
||||
</>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -109,7 +109,7 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
className="absolute left-1/2 top-1/2 -z-10 size-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
@@ -145,7 +145,7 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
{paidFeatures.map((feature) => (
|
||||
<li key={feature.title} className="flex items-center">
|
||||
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
||||
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
||||
<CheckIcon className="size-5 p-0.5 text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature.title}</span>
|
||||
{feature.comingSoon && (
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ export const SecurityListTip = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<div className="flex items-center gap-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="size-5 flex-shrink-0 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("workspace.settings.general.security_list_tip")}{" "}
|
||||
<Link
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ const ElementCheckbox = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<div className="my-1 flex items-center gap-x-2">
|
||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
@@ -330,7 +330,7 @@ export const AddIntegrationModal = ({
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent className="overflow-visible md:overflow-visible">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
|
||||
+4
-3
@@ -93,9 +93,9 @@ export const ManageIntegration = ({
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("workspace.integrations.connected_with_email", {
|
||||
email: airtableIntegration.config.email,
|
||||
@@ -106,7 +106,7 @@ export const ManageIntegration = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
<RefreshCcwIcon className="mr-2 size-4" />
|
||||
{t("workspace.integrations.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -134,6 +134,7 @@ export const ManageIntegration = ({
|
||||
|
||||
{integrationData.map((data, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
|
||||
+2
-2
@@ -247,7 +247,7 @@ export const AddIntegrationModal = ({
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
@@ -297,7 +297,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{surveyElements.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
<div key={question.id} className="my-1 flex items-center gap-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
|
||||
+4
-3
@@ -84,9 +84,9 @@ export const ManageIntegration = ({
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("workspace.integrations.connected_with_email", {
|
||||
email: googleSheetIntegration.config.email,
|
||||
@@ -97,7 +97,7 @@ export const ManageIntegration = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleGoogleAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
<RefreshCcwIcon className="mr-2 size-4" />
|
||||
{t("workspace.integrations.google_sheets.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -132,6 +132,7 @@ export const ManageIntegration = ({
|
||||
{integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
|
||||
+1
-1
@@ -277,7 +277,7 @@ export const AddIntegrationModal = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex items-start space-x-2">
|
||||
<div className="mb-4 flex items-start gap-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
|
||||
+4
-3
@@ -68,9 +68,9 @@ export const ManageIntegration = ({
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("workspace.integrations.notion.connected_with_workspace", {
|
||||
workspace: notionIntegration.config.key.workspace_name,
|
||||
@@ -81,7 +81,7 @@ export const ManageIntegration = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleNotionAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
<RefreshCcwIcon className="mr-2 size-4" />
|
||||
{t("workspace.integrations.notion.update_connection")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -113,6 +113,7 @@ export const ManageIntegration = ({
|
||||
{integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
|
||||
+2
-2
@@ -190,7 +190,7 @@ export const MappingRow = ({
|
||||
elem={mapping[idx].element}
|
||||
t={t}
|
||||
/>
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="flex w-full items-center gap-x-2">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
@@ -212,7 +212,7 @@ export const MappingRow = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-x-2">
|
||||
{mapping.length > 1 && (
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow} type="button">
|
||||
<TrashIcon />
|
||||
|
||||
+3
-3
@@ -221,7 +221,7 @@ export const AddChannelMappingModal = ({
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
@@ -255,7 +255,7 @@ export const AddChannelMappingModal = ({
|
||||
target="_blank"
|
||||
className="text-xs">
|
||||
<Button variant="ghost" size="sm" className="my-2" type="button">
|
||||
<CircleHelpIcon className="h-4 w-4" />
|
||||
<CircleHelpIcon className="size-4" />
|
||||
{t("workspace.integrations.slack.dont_see_your_channel")}
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -290,7 +290,7 @@ export const AddChannelMappingModal = ({
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{surveyElements.map((element) => (
|
||||
<div key={element.id} className="my-1 flex items-center space-x-2">
|
||||
<div key={element.id} className="my-1 flex items-center gap-x-2">
|
||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
|
||||
+4
-3
@@ -71,7 +71,7 @@ export const ManageIntegration = ({
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
{showReconnectButton && (
|
||||
<div className="mb-4 flex w-full items-center justify-between space-x-4">
|
||||
<div className="mb-4 flex w-full items-center justify-between gap-x-4">
|
||||
<p className="text-amber-700">
|
||||
<Trans
|
||||
i18nKey="workspace.integrations.slack.slack_reconnect_button_description"
|
||||
@@ -83,9 +83,9 @@ export const ManageIntegration = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-4">
|
||||
<div className="flex w-full justify-end gap-x-4">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("workspace.integrations.slack.connected_with_team", {
|
||||
team: slackIntegration.config.key.team?.name,
|
||||
@@ -119,6 +119,7 @@ export const ManageIntegration = ({
|
||||
{integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${index}-${data.surveyName}-${data.channelName}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export const EmptyAppSurveys = () => {
|
||||
const { workspace } = useWorkspace();
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-8 bg-slate-100 py-12">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-slate-200 bg-white">
|
||||
<div className="flex size-20 items-center justify-center rounded-full border border-slate-200 bg-white">
|
||||
<Unplug size={48} className="text-amber-500" absoluteStrokeWidth />
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ export const SurveyAnalysisNavigation = ({ survey, activeId }: SurveyAnalysisNav
|
||||
{
|
||||
id: "summary",
|
||||
label: t("common.summary"),
|
||||
icon: <PresentationIcon className="h-5 w-5" />,
|
||||
icon: <PresentationIcon className="size-5" />,
|
||||
href: `${url}/summary?referer=true`,
|
||||
current: pathname?.includes("/summary"),
|
||||
onClick: () => {
|
||||
@@ -34,7 +34,7 @@ export const SurveyAnalysisNavigation = ({ survey, activeId }: SurveyAnalysisNav
|
||||
{
|
||||
id: "responses",
|
||||
label: t("common.responses"),
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
icon: <InboxIcon className="size-5" />,
|
||||
href: `${url}/responses?referer=true`,
|
||||
current: pathname?.includes("/responses"),
|
||||
onClick: () => {
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ const ResponseTableCellComponent = ({
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
<Maximize2Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
+22
-22
@@ -47,8 +47,8 @@ const getElementColumnsData = (
|
||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||
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]}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{ELEMENTS_ICON_MAP[elementType]}</span>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,8 +85,8 @@ const getElementColumnsData = (
|
||||
header: () => {
|
||||
return (
|
||||
<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["matrix"]}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
|
||||
<span className="truncate">
|
||||
{getTextContent(getLocalizedValue(element.headline, "default")) +
|
||||
" - " +
|
||||
@@ -112,8 +112,8 @@ const getElementColumnsData = (
|
||||
header: () => {
|
||||
return (
|
||||
<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["address"]}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{ELEMENTS_ICON_MAP["address"]}</span>
|
||||
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,8 +135,8 @@ const getElementColumnsData = (
|
||||
header: () => {
|
||||
return (
|
||||
<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["contactInfo"]}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
|
||||
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,8 +202,8 @@ const getElementColumnsData = (
|
||||
accessorKey: "ELEMENT_" + element.id,
|
||||
header: () => (
|
||||
<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[element.type]}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{ELEMENTS_ICON_MAP[element.type]}</span>
|
||||
<span className="truncate">
|
||||
{getTextContent(
|
||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
||||
@@ -241,8 +241,8 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
|
||||
metadataColumns.push({
|
||||
accessorKey: "METADATA_" + label,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
|
||||
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
|
||||
</div>
|
||||
),
|
||||
@@ -290,7 +290,7 @@ export const generateResponseTableColumns = (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
|
||||
<CircleHelpIcon className="size-3 text-slate-500" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="space-x-1 font-normal">
|
||||
<span>{t("workspace.surveys.responses.how_to_identify_users")}</span>
|
||||
@@ -363,7 +363,7 @@ export const generateResponseTableColumns = (
|
||||
<ResponseBadges
|
||||
items={tagsArray.map((tag) => ({ value: tag }))}
|
||||
isExpanded={isExpanded}
|
||||
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
|
||||
icon={<TagIcon className="size-4 text-slate-500" />}
|
||||
showId={false}
|
||||
/>
|
||||
);
|
||||
@@ -375,8 +375,8 @@ export const generateResponseTableColumns = (
|
||||
return {
|
||||
accessorKey: "VARIABLE_" + variable.id,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">{VARIABLES_ICON_MAP[variable.type]}</span>
|
||||
<span className="truncate">{variable.name}</span>
|
||||
</div>
|
||||
),
|
||||
@@ -394,9 +394,9 @@ export const generateResponseTableColumns = (
|
||||
return {
|
||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">
|
||||
<EyeOffIcon className="size-4" />
|
||||
</span>
|
||||
<span className="truncate">{hiddenFieldId}</span>
|
||||
</div>
|
||||
@@ -416,9 +416,9 @@ export const generateResponseTableColumns = (
|
||||
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "verifiedEmail",
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
<MailIcon className="h-4 w-4" />
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<span className="size-4">
|
||||
<MailIcon className="size-4" />
|
||||
</span>
|
||||
<span className="truncate">{t("common.verified_email")}</span>
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -25,9 +25,9 @@ export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProp
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
if (scale === "number") return <CircleSlash2 className="size-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="size-4" />;
|
||||
}, [elementSummary.element.scale]);
|
||||
|
||||
return (
|
||||
@@ -36,8 +36,8 @@ export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProp
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
|
||||
|
||||
+6
-6
@@ -27,9 +27,9 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
if (scale === "number") return <CircleSlash2 className="size-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="size-4" />;
|
||||
}, [elementSummary.element.scale]);
|
||||
|
||||
return (
|
||||
@@ -38,8 +38,8 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
||||
@@ -49,7 +49,7 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
{t("workspace.surveys.summary.csat_satisfied", {
|
||||
|
||||
+4
-4
@@ -24,16 +24,16 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
||||
additionalInfo={
|
||||
<>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{`${elementSummary.impressionCount} ${t("common.impressions")}`}
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{`${elementSummary.clickCount} ${t("common.clicks")}`}
|
||||
</div>
|
||||
{!elementSummary.element.required && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{`${elementSummary.skipCount} ${t("common.skips")}`}
|
||||
</div>
|
||||
)}
|
||||
@@ -42,7 +42,7 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="mr-8 flex gap-x-1">
|
||||
<p className="font-semibold text-slate-700">CTR</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="mr-8 flex gap-x-1">
|
||||
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
@@ -36,7 +36,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="mr-8 flex gap-x-1">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export const ClickableBarSegment = ({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className={className} style={style} onClick={onClick}>
|
||||
<button type="button" className={className} style={style} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
+2
-1
@@ -41,6 +41,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group w-full cursor-pointer"
|
||||
key={summaryItem.title}
|
||||
onClick={() =>
|
||||
@@ -53,7 +54,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="mr-8 flex gap-x-1">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{summaryItem.title}
|
||||
</p>
|
||||
|
||||
+3
-3
@@ -39,15 +39,15 @@ export const ElementSummaryHeader = ({
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
<div className="flex gap-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
{elementType && <elementType.icon className="mr-2 h-4 w-4" />}
|
||||
{elementType && <elementType.icon className="mr-2 size-4" />}
|
||||
{elementType ? elementType.label : t("workspace.surveys.summary.unknown_question_type")}{" "}
|
||||
{t("common.question")}
|
||||
</div>
|
||||
{showResponses && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ export const FileUploadSummary = ({ elementSummary, survey, locale }: FileUpload
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -34,13 +34,13 @@ export const HiddenFieldsSummary = ({ elementSummary, locale }: HiddenFieldsSumm
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
<div className="flex gap-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
|
||||
<MessageSquareTextIcon className="mr-2 size-4" />
|
||||
Hidden Field
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
@@ -76,6 +76,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
elementSummary.data[rowIndex].totalResponsesForRow
|
||||
)}>
|
||||
<button
|
||||
type="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"
|
||||
onClick={() =>
|
||||
|
||||
+4
-4
@@ -74,7 +74,7 @@ export const MultipleChoiceSummary = ({
|
||||
additionalInfo={
|
||||
elementSummary.type === "multipleChoiceMulti" ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -102,13 +102,13 @@ export const MultipleChoiceSummary = ({
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<div className="flex w-full gap-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 })}
|
||||
</p>
|
||||
@@ -150,7 +150,7 @@ export const MultipleChoiceSummary = ({
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||
<div className="ph-no-capture col-span-1 flex items-center gap-x-4 pl-6 font-medium text-slate-900">
|
||||
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||
<span>
|
||||
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||
|
||||
+6
-5
@@ -85,7 +85,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
|
||||
@@ -105,10 +105,10 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="size-4" />}>
|
||||
{t("workspace.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="size-4" />}>
|
||||
{t("workspace.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -119,12 +119,13 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="mr-8 flex gap-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
@@ -179,7 +180,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
|
||||
<div className="mb-1 flex items-center space-x-1">
|
||||
<div className="mb-1 flex items-center gap-x-1">
|
||||
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
|
||||
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
|
||||
{convertFloatToNDecimal(choice.percentage, 1)}%
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
additionalInfo={
|
||||
elementSummary.element.allowMulti ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
<InboxIcon className="mr-2 size-4" />
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -60,7 +60,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
|
||||
<div className="relative h-32 w-[220px]">
|
||||
<Image
|
||||
src={result.imageUrl}
|
||||
@@ -72,7 +72,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
</div>
|
||||
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<div className="flex w-full gap-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 })}
|
||||
</p>
|
||||
|
||||
+3
-3
@@ -26,14 +26,14 @@ export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps)
|
||||
return (
|
||||
<div key={result.value} className="group cursor-pointer">
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
|
||||
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
<span className="ml-auto flex items-center space-x-1">
|
||||
<span className="ml-auto flex items-center gap-x-1">
|
||||
<span className="font-bold text-slate-600">
|
||||
#{convertFloatToNDecimal(result.avgRanking, 2)}
|
||||
</span>
|
||||
|
||||
+4
-3
@@ -55,10 +55,10 @@ export const RatingLikeSummary = ({
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="size-4" />}>
|
||||
{t("workspace.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="size-4" />}>
|
||||
{t("workspace.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -158,6 +158,7 @@ export const RatingLikeSummary = ({
|
||||
{elementSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -169,7 +170,7 @@ export const RatingLikeSummary = ({
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="mr-8 flex items-center gap-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={elementSummary.element.scale}
|
||||
|
||||
+2
-2
@@ -11,11 +11,11 @@ interface RatingScaleLegendProps {
|
||||
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
|
||||
return (
|
||||
<div className="mt-3 flex w-full items-start justify-between px-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
|
||||
<span className="text-xs text-slate-500">1</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="text-xs text-slate-500">{range}</span>
|
||||
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -25,9 +25,9 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
if (scale === "number") return <CircleSlash2 className="size-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="size-4" />;
|
||||
}, [elementSummary.element.scale]);
|
||||
|
||||
return (
|
||||
@@ -36,8 +36,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
|
||||
showButton ? "opacity-80" : "opacity-0"
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const getIcon = (elementType: TSurveyElementTypeEnum) => {
|
||||
const Icon = getElementIcon(elementType, t);
|
||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||
return <Icon className="mt-[3px] size-5 shrink-0 text-slate-600" />;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TimerIcon className="h-5 w-5" />
|
||||
<TimerIcon className="size-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="text-center font-normal">{t("workspace.surveys.summary.ttc_tooltip")}</p>
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ export const SummaryImpressions = ({
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircleIcon className="h-5 w-5" />
|
||||
<AlertCircleIcon className="size-5" />
|
||||
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{displaysError}</p>
|
||||
@@ -116,7 +116,7 @@ export const SummaryImpressions = ({
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<InfoIcon className="h-4 w-4 shrink-0" />
|
||||
<InfoIcon className="size-4 shrink-0" />
|
||||
<span>{t("workspace.surveys.summary.impressions_identified_only")}</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ export const InteractiveCard = ({
|
||||
{isLoading ? <div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div> : value}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{isActive ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
||||
<div className="flex size-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{isActive ? <ChevronUpIcon className="size-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -246,7 +246,7 @@ export const AnonymousLinksTab = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
<div className="flex h-full flex-col justify-between gap-y-4">
|
||||
<div className="flex w-full grow flex-col gap-6">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="multi-use-link-switch"
|
||||
@@ -354,7 +354,7 @@ export const AnonymousLinksTab = ({
|
||||
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
|
||||
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
|
||||
<CirclePlayIcon className="size-3.5 shrink-0 text-slate-50" />
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-50">
|
||||
|
||||
+9
-9
@@ -149,8 +149,8 @@ export const AppTab = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-6 pb-4">
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="flex flex-col justify-between gap-y-6 pb-4">
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<Alert variant={workspace.appSetupCompleted ? "success" : "warning"} size="default">
|
||||
<AlertTitle>
|
||||
{workspace.appSetupCompleted
|
||||
@@ -171,14 +171,14 @@ export const AppTab = () => {
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<H4>{t("workspace.surveys.summary.in_app.display_criteria")}</H4>
|
||||
<div
|
||||
className={
|
||||
"flex w-full flex-col space-y-4 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm"
|
||||
}>
|
||||
<DisplayCriteriaItem
|
||||
icon={<TimerResetIcon className="h-4 w-4" />}
|
||||
icon={<TimerResetIcon className="size-4" />}
|
||||
title={waitTime()}
|
||||
titleSuffix={
|
||||
survey.recontactDays !== null
|
||||
@@ -188,7 +188,7 @@ export const AppTab = () => {
|
||||
description={t("workspace.surveys.summary.in_app.display_criteria.time_based_description")}
|
||||
/>
|
||||
<DisplayCriteriaItem
|
||||
icon={<UsersIcon className="h-4 w-4" />}
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title={getSegmentTitle(survey.segment)}
|
||||
description={t("workspace.surveys.summary.in_app.display_criteria.audience_description")}
|
||||
/>
|
||||
@@ -197,9 +197,9 @@ export const AppTab = () => {
|
||||
key={trigger.actionClass.id}
|
||||
icon={
|
||||
trigger.actionClass.type === "code" ? (
|
||||
<CodeXmlIcon className="h-4 w-4" />
|
||||
<CodeXmlIcon className="size-4" />
|
||||
) : (
|
||||
<MousePointerClickIcon className="h-4 w-4" />
|
||||
<MousePointerClickIcon className="size-4" />
|
||||
)
|
||||
}
|
||||
title={trigger.actionClass.name}
|
||||
@@ -209,7 +209,7 @@ export const AppTab = () => {
|
||||
))}
|
||||
{survey.displayPercentage !== null && survey.displayPercentage > 0 && (
|
||||
<DisplayCriteriaItem
|
||||
icon={<PercentIcon className="h-4 w-4" />}
|
||||
icon={<PercentIcon className="size-4" />}
|
||||
title={t("workspace.surveys.summary.in_app.display_criteria.randomizer", {
|
||||
percentage: survey.displayPercentage,
|
||||
})}
|
||||
@@ -219,7 +219,7 @@ export const AppTab = () => {
|
||||
/>
|
||||
)}
|
||||
<DisplayCriteriaItem
|
||||
icon={<Repeat1Icon className="h-4 w-4" />}
|
||||
icon={<Repeat1Icon className="size-4" />}
|
||||
title={displayOption()}
|
||||
description={t("workspace.surveys.summary.in_app.display_criteria.recontact_description")}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtml
|
||||
</Button>
|
||||
{/* Security Warning */}
|
||||
<Alert variant="warning" className="flex items-start gap-2">
|
||||
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<AlertTriangleIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<AlertDescription>{t("workspace.surveys.share.custom_html.security_warning")}</AlertDescription>
|
||||
</Alert>
|
||||
</form>
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export const DocumentationLinksSection = ({ title, links }: DocumentationLinksSe
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col space-y-3">
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<H4>{title}</H4>
|
||||
{links.map((link) => (
|
||||
<Alert key={link.title} size="small" variant="default">
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col space-y-2">
|
||||
<div className="flex w-full flex-col gap-y-2">
|
||||
{links.map((link) => (
|
||||
<div key={link.title} className="flex w-full flex-col gap-3">
|
||||
<Alert variant="outbound" size="small">
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const DynamicPopupTab = ({ surveyId }: DynamicPopupTabProps) => {
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between space-y-4" data-testid="dynamic-popup-container">
|
||||
<div className="flex h-full flex-col justify-between gap-y-4" data-testid="dynamic-popup-container">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>{t("workspace.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
|
||||
<AlertDescription>{t("workspace.surveys.share.dynamic_popup.alert_description")}</AlertDescription>
|
||||
|
||||
+4
-4
@@ -131,9 +131,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
|
||||
data-testid="survey-email-preview-shell">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||
<div className="size-3 rounded-full bg-red-500" />
|
||||
<div className="size-3 rounded-full bg-amber-500" />
|
||||
<div className="size-3 rounded-full bg-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
|
||||
@@ -205,7 +205,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col space-y-4">
|
||||
<div className="flex h-full w-full flex-col gap-y-4">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeId={activeTab}
|
||||
|
||||
+2
-2
@@ -184,7 +184,7 @@ export const PersonalLinksTab = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
<div className="flex h-full flex-col justify-between gap-y-4">
|
||||
<FormProvider {...form}>
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* Recipients Section */}
|
||||
@@ -247,7 +247,7 @@ export const PersonalLinksTab = ({
|
||||
disabled={isButtonDisabled}
|
||||
loading={isGenerating}
|
||||
className="w-fit">
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
<DownloadIcon className="mr-2 size-4" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -169,7 +169,7 @@ export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabP
|
||||
{survey.slug && !isEditing && (
|
||||
<>
|
||||
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Copy className="mr-2 size-4" />
|
||||
{t("common.copy")} URL
|
||||
</Button>
|
||||
<Button
|
||||
@@ -177,7 +177,7 @@ export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabP
|
||||
variant="destructive"
|
||||
onClick={() => setShowRemoveDialog(true)}
|
||||
disabled={isReadOnly}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
{t("common.remove")}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
+4
-4
@@ -77,7 +77,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
|
||||
<LoaderCircle className="size-8 animate-spin text-slate-500" />
|
||||
<p className="text-sm text-slate-500">{t("workspace.surveys.summary.generating_qr_code")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,7 +91,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
|
||||
{!isLoading && !hasError && (
|
||||
<div className="flex flex-col items-start justify-center gap-4">
|
||||
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
|
||||
<div className="flex size-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
|
||||
<div ref={qrCodeRef} className="h-full w-full" />
|
||||
</div>
|
||||
<Button
|
||||
@@ -103,9 +103,9 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
? t("workspace.surveys.summary.downloading_qr_code")
|
||||
: t("workspace.surveys.summary.download_qr_code")}
|
||||
{isDownloading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -117,7 +117,7 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}
|
||||
disabled={tab.disabled}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<tab.icon className="size-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -145,7 +145,7 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
? "bg-white text-slate-900 shadow-sm hover:bg-white"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<tab.icon className="size-4 text-slate-700" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
))}
|
||||
|
||||
+4
-4
@@ -62,27 +62,27 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
type="button"
|
||||
onClick={() => handleViewChange("share")}
|
||||
className="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">
|
||||
<Share2Icon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
<Share2Icon className="size-8 stroke-1 text-slate-900" />
|
||||
{t("workspace.surveys.summary.share_survey")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEmbedViewWithTab(tabs[1].id)}
|
||||
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" />
|
||||
<UserIcon className="size-8 stroke-1 text-slate-900" />
|
||||
{t("workspace.surveys.summary.use_personal_links")}
|
||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/workspaces/${workspace?.id}/settings/account/notifications`}
|
||||
className="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">
|
||||
<BellRing className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
<BellRing className="size-8 stroke-1 text-slate-900" />
|
||||
{t("workspace.surveys.summary.configure_alerts")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/workspaces/${workspace?.id}/settings/workspace/integrations`}
|
||||
className="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">
|
||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
<BlocksIcon className="size-8 stroke-1 text-slate-900" />
|
||||
{t("workspace.surveys.summary.setup_integrations")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ interface TabContainerProps {
|
||||
|
||||
export const TabContainer = ({ title, description, children }: TabContainerProps) => {
|
||||
return (
|
||||
<div className="flex h-full grow flex-col items-start space-y-4">
|
||||
<div className="flex h-full grow flex-col items-start gap-y-4">
|
||||
<div className="pb-2">
|
||||
<H3>{title}</H3>
|
||||
<Small color="muted" margin="headerDescription">
|
||||
|
||||
+1
-1
@@ -389,7 +389,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("common.download")}
|
||||
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isDownloading && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
|
||||
</span>
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
+2
-2
@@ -158,7 +158,7 @@ export const ElementFilterComboBox = ({
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
|
||||
{filterOptions.length > 1 && <ChevronIcon className="size-4 flex-shrink-0 opacity-50" />}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions.map((o, index) => {
|
||||
@@ -203,7 +203,7 @@ export const ElementFilterComboBox = ({
|
||||
onClick={(e) => handleRemoveTag(e, value)}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
|
||||
{value}
|
||||
<X className="h-3 w-3" />
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
+3
-3
@@ -26,8 +26,8 @@ import {
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmilePlusIcon,
|
||||
SmartphoneIcon,
|
||||
SmilePlusIcon,
|
||||
StarIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@@ -140,7 +140,7 @@ const getIcon = (type: string) => {
|
||||
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
|
||||
type
|
||||
];
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
return IconComponent ? <IconComponent className="size-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
const getIconBackground = (type: OptionsType | string): string => {
|
||||
@@ -231,7 +231,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon className="h-4 w-4 opacity-50" />
|
||||
<ChevronIcon className="size-4 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-3
@@ -57,9 +57,9 @@ export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverT
|
||||
<span className="text-sm text-slate-700">{children}</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
<ChevronUp className="ml-2 size-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="ml-2 size-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -252,7 +252,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<p className="font-semibold text-slate-800">
|
||||
{t("workspace.surveys.summary.show_all_responses_that_match")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Select
|
||||
value={filterValue.responseStatus ?? "all"}
|
||||
onValueChange={(val) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
test("returns 404 notFound for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
code: "not_found",
|
||||
message: "Survey not found",
|
||||
details: {
|
||||
resource_id: "id-1",
|
||||
resource_type: "Survey",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
|
||||
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
error instanceof ResourceNotFoundError
|
||||
) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(error.resourceType, error.resourceId);
|
||||
}
|
||||
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Some error occurred");
|
||||
|
||||
+30
-2
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
|
||||
getSurvey: vi.fn(),
|
||||
getValidatedResponseUpdateInput: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
});
|
||||
|
||||
test("returns a bad request response when the response id is missing", async () => {
|
||||
@@ -245,6 +245,34 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns not found when the workspace id cannot be resolved", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue(null);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Workspace not found",
|
||||
details: {
|
||||
resource_id: "unknown_workspace_or_env",
|
||||
resource_type: "Workspace",
|
||||
},
|
||||
});
|
||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
|
||||
|
||||
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
...getBaseSurvey(),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -155,6 +160,16 @@ describe("createResponse", () => {
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["displayId"] },
|
||||
});
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("should throw original error on other Prisma errors", async () => {
|
||||
const genericError = new Error("Generic database error");
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
|
||||
@@ -2,7 +2,12 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { assertDisplayOwnership } from "@/lib/display/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -104,6 +110,16 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contact?.id ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
@@ -131,6 +147,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -190,7 +195,19 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["someOtherField"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
@@ -199,7 +216,7 @@ describe("createResponse V2", () => {
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
|
||||
@@ -2,7 +2,12 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -12,6 +17,7 @@ import {
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
|
||||
import { assertDisplayOwnership } from "@/lib/display/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -99,6 +105,16 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contactId ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
@@ -122,6 +138,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -415,6 +415,44 @@ describe("withV3ApiWrapper", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns 413 problem response for oversized JSON input", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-payload-too-large",
|
||||
},
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "payload_too_large",
|
||||
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
requestId: "req-payload-too-large",
|
||||
status: 413,
|
||||
title: "Payload Too Large",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid route params", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
|
||||
@@ -40,6 +40,40 @@ describe("parseAndValidateJsonBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a payload too large response when the request body exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.issue).toBe("payload_too_large");
|
||||
expect(result.response.status).toBe(413);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "payload_too_large",
|
||||
message: "Payload Too Large",
|
||||
details: {
|
||||
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a validation response when the parsed JSON does not match the schema", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
|
||||
RequestBodyTooLargeError,
|
||||
parseJsonBodyWithLimit,
|
||||
readRequestBodyWithLimit,
|
||||
} from "./request-body";
|
||||
|
||||
const createStreamingRequest = (chunks: string[]): Request =>
|
||||
new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
describe("request body parsing", () => {
|
||||
test("rejects a request when content-length exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
|
||||
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
|
||||
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
|
||||
name: "RequestBodyTooLargeError",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
|
||||
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
|
||||
});
|
||||
|
||||
test("allows a body exactly at the body limit", async () => {
|
||||
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const body = await readRequestBodyWithLimit(request);
|
||||
|
||||
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
||||
expect(body).toBe(rawBody);
|
||||
});
|
||||
|
||||
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: "{invalid-json",
|
||||
});
|
||||
|
||||
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
|
||||
});
|
||||
|
||||
test("returns an empty string for requests without a body", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const selectDisplay = {
|
||||
@@ -146,6 +146,58 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplayForResponseValidation = async (
|
||||
displayId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<{
|
||||
surveyId: string;
|
||||
workspaceId: string;
|
||||
responseId: string | null;
|
||||
contactId: string | null;
|
||||
} | null> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
const client = tx ?? prisma;
|
||||
try {
|
||||
const display = await client.display.findUnique({
|
||||
where: { id: displayId },
|
||||
select: {
|
||||
surveyId: true,
|
||||
contactId: true,
|
||||
response: { select: { id: true } },
|
||||
survey: { select: { workspaceId: true } },
|
||||
},
|
||||
});
|
||||
if (!display) return null;
|
||||
return {
|
||||
surveyId: display.surveyId,
|
||||
workspaceId: display.survey.workspaceId,
|
||||
responseId: display.response?.id ?? null,
|
||||
contactId: display.contactId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const assertDisplayOwnership = async (
|
||||
displayId: string,
|
||||
workspaceId: string,
|
||||
surveyId: string,
|
||||
contactId: string | null,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<void> => {
|
||||
const display = await getDisplayForResponseValidation(displayId, tx);
|
||||
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
|
||||
if (display.workspaceId !== workspaceId)
|
||||
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
|
||||
if (display.surveyId !== surveyId)
|
||||
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
|
||||
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
|
||||
if (display.contactId !== null && display.contactId !== contactId)
|
||||
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
|
||||
};
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -3,14 +3,18 @@ import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
assertDisplayOwnership,
|
||||
getDisplayCountBySurveyId,
|
||||
getDisplayForResponseValidation,
|
||||
getDisplaysByContactId,
|
||||
getDisplaysBySurveyIdWithContact,
|
||||
} from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
|
||||
const mockResponseId = "clqnfg59i000208i426pb4wcv";
|
||||
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
@@ -290,3 +294,96 @@ describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockDisplayRecord = {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: null as string | null,
|
||||
response: null as { id: string } | null,
|
||||
survey: { workspaceId: mockWorkspaceId },
|
||||
};
|
||||
|
||||
describe("getDisplayForResponseValidation", () => {
|
||||
test("returns null when display is not found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
|
||||
const result = await getDisplayForResponseValidation(mockDisplayId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns mapped shape when display is found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: mockContactId,
|
||||
response: { id: mockResponseId },
|
||||
} as any);
|
||||
const result = await getDisplayForResponseValidation(mockDisplayId);
|
||||
expect(result).toEqual({
|
||||
surveyId: mockSurveyId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
responseId: mockResponseId,
|
||||
contactId: mockContactId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
})
|
||||
);
|
||||
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDisplayOwnership", () => {
|
||||
test("throws InvalidInputError when display is not found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
|
||||
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when workspaceId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when surveyId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when display is already linked to a response", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
response: { id: mockResponseId },
|
||||
} as any);
|
||||
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when contactId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: "contact-a",
|
||||
} as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("resolves without error when all ownership checks pass", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: mockContactId,
|
||||
} as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponse } from "./service";
|
||||
@@ -324,5 +325,35 @@ describe("updateResponse", () => {
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -569,6 +570,13 @@ export const updateResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Response", responseId);
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in cell values", async () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p, age: 0 }));
|
||||
const csv = await convertToCsv(["name", "age"], rows);
|
||||
const lines = csv.trim().split("\n").slice(1); // drop header
|
||||
payloads.forEach((p, i) => {
|
||||
// each value should be prefixed with a single quote so the spreadsheet
|
||||
// app treats it as text rather than a formula
|
||||
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in field/header names", async () => {
|
||||
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=evil","age"');
|
||||
expect(lines[1]).toBe('"x",1');
|
||||
});
|
||||
|
||||
test("should not alter benign strings", async () => {
|
||||
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[1]).toBe('"Alice = Bob"');
|
||||
});
|
||||
|
||||
test("should preserve distinct columns whose labels collide after sanitization", async () => {
|
||||
// "=field" and "'=field" both render as "'=field" once defanged, but the
|
||||
// underlying row keys must stay distinct so neither cell is dropped.
|
||||
const csv = await convertToCsv(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=field","\'=field"');
|
||||
expect(lines[1]).toBe('"a","b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
expect(cleaned).toEqual(data);
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in xlsx cells", () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p }));
|
||||
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
payloads.forEach((p, i) => {
|
||||
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||
// value stored as plain text, not as a formula (no `f` property)
|
||||
expect(cell.f).toBeUndefined();
|
||||
expect(cell.v).toBe(`'${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in xlsx header names", () => {
|
||||
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
const headerCell = sheet["A1"];
|
||||
expect(headerCell.f).toBeUndefined();
|
||||
expect(headerCell.v).toBe("'=evil");
|
||||
// benign header untouched
|
||||
expect(sheet["B1"].v).toBe("name");
|
||||
// data row mapped via original key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
expect(sheet["B2"].v).toBe("Alice");
|
||||
});
|
||||
|
||||
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
|
||||
// Original keys "=field" and "'=field" both render as "'=field"; ensure
|
||||
// both cells survive instead of one overwriting the other.
|
||||
const buffer = convertToXlsxBuffer(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
expect(sheet["A1"].v).toBe("'=field");
|
||||
expect(sheet["B1"].v).toBe("'=field");
|
||||
expect(sheet["A2"].v).toBe("a");
|
||||
expect(sheet["B2"].v).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
variant="secondary"
|
||||
title={t("common.select_language")}
|
||||
aria-label={t("common.select_language")}>
|
||||
<Languages className="h-5 w-5" />
|
||||
<Languages className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -63,7 +63,7 @@ export const ShareSurveyLink = ({
|
||||
key={surveyUrl}
|
||||
enforceSurveyUrlWidth={enforceSurveyUrlWidth}
|
||||
/>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
|
||||
<Button
|
||||
disabled={!surveyUrl}
|
||||
|
||||
@@ -35,13 +35,13 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps)
|
||||
{hiddenFieldsData.map((fieldData) => {
|
||||
return (
|
||||
<div key={fieldData.field}>
|
||||
<div className="flex space-x-2 text-sm text-slate-500">
|
||||
<div className="flex gap-x-2 text-sm text-slate-500">
|
||||
<p>{fieldData.field}</p>
|
||||
<div className="flex items-center space-x-2 rounded-full bg-slate-100 px-2">
|
||||
<div className="flex items-center gap-x-2 rounded-full bg-slate-100 px-2">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
<EyeOffIcon className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]" side="top">
|
||||
{t("common.hidden_field")}
|
||||
|
||||
+3
-3
@@ -122,7 +122,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
<ResponseBadges
|
||||
items={[{ value: responseData.toString() }]}
|
||||
isExpanded={isExpanded}
|
||||
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
|
||||
icon={<PhoneIcon className="size-4 text-slate-500" />}
|
||||
showId={showId}
|
||||
/>
|
||||
);
|
||||
@@ -134,7 +134,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
<ResponseBadges
|
||||
items={[{ value: responseData.toString() }]}
|
||||
isExpanded={isExpanded}
|
||||
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
|
||||
icon={<CheckCheckIcon className="size-4 text-slate-500" />}
|
||||
showId={showId}
|
||||
/>
|
||||
);
|
||||
@@ -146,7 +146,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
<ResponseBadges
|
||||
items={[{ value: responseData.toString() }]}
|
||||
isExpanded={isExpanded}
|
||||
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
|
||||
icon={<MousePointerClickIcon className="size-4 text-slate-500" />}
|
||||
showId={showId}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user