mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-26 18:01:33 -05:00
Compare commits
5 Commits
add-inheri
...
fix/naviga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876ce99c89 | ||
|
|
59cc9c564e | ||
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
deb062dd03 |
@@ -1,83 +0,0 @@
|
||||
---
|
||||
name: inherit-font-toggle-plan
|
||||
overview: Add an easy “inherit host page font” design using the existing `fontFamily` styling field, mapped to CSS variable fallback behavior in the SDK. Keep it backward-compatible and minimize schema churn by avoiding new persisted fields.
|
||||
todos:
|
||||
- id: sdk-font-fallback
|
||||
content: Switch SDK preflight font-family to var(--fb-font-family, inherit) in surveys preflight CSS.
|
||||
status: pending
|
||||
- id: inject-font-variable
|
||||
content: Wire styling.fontFamily into addCustomThemeToDom to emit --fb-font-family only when set.
|
||||
status: pending
|
||||
- id: editor-toggle-ui
|
||||
content: Add inherit-font toggle + conditional font stack input in shared FormStylingSettings component.
|
||||
status: pending
|
||||
- id: i18n-keys
|
||||
content: Add English translation keys for new toggle and font stack field text.
|
||||
status: pending
|
||||
- id: tests
|
||||
content: Add/extend tests for CSS variable emission and UI toggle-to-fontFamily mapping.
|
||||
status: pending
|
||||
- id: manual-verification
|
||||
content: Validate inline/modal behavior with host-font inherit ON and explicit stack OFF.
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Inherit Font Toggle (Easy Design)
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a simple styling toggle that lets users choose whether surveys inherit the host page font, using existing `fontFamily` (no new DB/schema field).
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
- Represent toggle state via `fontFamily`:
|
||||
- **Inherit ON**: `fontFamily` is `null`/unset
|
||||
- **Inherit OFF**: `fontFamily` contains a font stack string
|
||||
- Make SDK preflight use a CSS variable fallback:
|
||||
- `font-family: var(--fb-font-family, inherit);`
|
||||
- Inject `--fb-font-family` only when `styling.fontFamily` is set.
|
||||
|
||||
## Changes by Area
|
||||
|
||||
- **SDK base font behavior**
|
||||
- Update [packages/surveys/src/styles/preflight.css](packages/surveys/src/styles/preflight.css) to replace hardcoded Inter stack with variable + inherit fallback.
|
||||
- **Theme/style variable injection**
|
||||
- Update [packages/surveys/src/lib/styles.ts](packages/surveys/src/lib/styles.ts) to append `--fb-font-family` from `styling.fontFamily` when present.
|
||||
- **Styling editor UX (workspace + survey reuse)**
|
||||
- Extend [apps/web/modules/survey/editor/components/form-styling-settings.tsx](apps/web/modules/survey/editor/components/form-styling-settings.tsx):
|
||||
- Add a toggle control for “Inherit font from host page”.
|
||||
- When disabled, show a text field for font stack (bind to `fontFamily`).
|
||||
- Keep this inside advanced styling section to reduce UI noise.
|
||||
- Because workspace theme and survey styling both reuse this component, this covers both entry points (including [apps/web/modules/projects/settings/look/components/theme-styling.tsx](apps/web/modules/projects/settings/look/components/theme-styling.tsx) and survey editor views) without duplicating UI code.
|
||||
- **Defaults and compatibility**
|
||||
- Ensure defaults continue to behave as inherit when `fontFamily` is absent (no mandatory updates to defaults object needed).
|
||||
- Verify existing saved stylings without `fontFamily` continue to render unchanged except adopting host font (intended behavior).
|
||||
- **Translations**
|
||||
- Add new i18n keys in [apps/web/locales/en-US.json](apps/web/locales/en-US.json) for the toggle label/description and custom-font input label/description.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
- Extend [packages/surveys/src/lib/styles.test.ts](packages/surveys/src/lib/styles.test.ts):
|
||||
- Assert `--fb-font-family` is emitted when `fontFamily` is provided.
|
||||
- Assert it is omitted when `fontFamily` is null/undefined.
|
||||
- Add/adjust editor component tests (where existing pattern for form controls exists) to verify toggle behavior updates `fontFamily` correctly.
|
||||
- Manual verification:
|
||||
- Host page with a distinctive font: confirm survey inherits when toggle ON.
|
||||
- Toggle OFF + custom stack: confirm survey uses configured stack.
|
||||
- Regression check in modal and inline renders.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- **Risk:** Host font may be unavailable in some contexts.
|
||||
- **Mitigation:** Custom stack path remains available when inherit is OFF.
|
||||
- **Risk:** Confusion between workspace and survey-level overrides.
|
||||
- **Mitigation:** Keep existing overwrite semantics; only map toggle to `fontFamily` value at the currently edited scope.
|
||||
- **Risk:** Iframe embeds cannot inherit outer-page font.
|
||||
- **Mitigation:** Document that iframe use requires explicit font stack (toggle OFF).
|
||||
|
||||
## Validation Commands (post-implementation)
|
||||
|
||||
- `pnpm test --filter @formbricks/surveys`
|
||||
- `pnpm lint`
|
||||
- Optional manual check in a host app with a non-default font to verify inheritance behavior.
|
||||
@@ -75,6 +75,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
publicDomain={publicDomain}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isLicenseActive={active}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
HotelIcon,
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
@@ -16,28 +21,40 @@ import {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
getOrganizationsForSwitcherAction,
|
||||
getProjectsForSwitcherAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -49,8 +66,30 @@ interface NavigationProps {
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
publicDomain: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||
if (pathname.includes("/settings/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||
if (pathname.includes("/(account)/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
@@ -60,6 +99,10 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isAccessControlAllowed,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -69,7 +112,8 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -153,6 +197,143 @@ export const MainNavigation = ({
|
||||
},
|
||||
];
|
||||
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
|
||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
href: `/environments/${environment.id}/workspace/look`,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/environments/${environment.id}/workspace/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `/environments/${environment.id}/workspace/integrations`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${environment.id}/workspace/teams`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `/environments/${environment.id}/workspace/languages`,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${environment.id}/workspace/tags`,
|
||||
},
|
||||
];
|
||||
|
||||
const organizationSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${environment.id}/settings/general`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
href: `/environments/${environment.id}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environment.id}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${environment.id}/settings/domain`,
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environment.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingProjects(true);
|
||||
setWorkspaceLoadError(null);
|
||||
getProjectsForSwitcherAction({ organizationId: organization.id }).then((result) => {
|
||||
if (result?.data) {
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, organization.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isOrganizationDropdownOpen ||
|
||||
organizations.length > 0 ||
|
||||
isLoadingOrganizations ||
|
||||
organizationLoadError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingOrganizations(true);
|
||||
setOrganizationLoadError(null);
|
||||
getOrganizationsForSwitcherAction({ organizationId: organization.id }).then((result) => {
|
||||
if (result?.data) {
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
setOrganizationLoadError(
|
||||
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
||||
);
|
||||
}
|
||||
setIsLoadingOrganizations(false);
|
||||
});
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
organizationLoadError,
|
||||
organization.id,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
@@ -184,6 +365,71 @@ export const MainNavigation = ({
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
if (projectId === project.id) return;
|
||||
startTransition(() => {
|
||||
router.push(`/workspaces/${projectId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === organization.id) return;
|
||||
startTransition(() => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettingNavigation = (href: string) => {
|
||||
startTransition(() => {
|
||||
router.push(href);
|
||||
});
|
||||
};
|
||||
|
||||
const handleProjectCreate = () => {
|
||||
if (projects.length >= organizationProjectsLimit) {
|
||||
setOpenProjectLimitModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenCreateProjectModal(true);
|
||||
};
|
||||
|
||||
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||
if (isFormbricksCloud) {
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenProjectLimitModal(false),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: isLicenseActive
|
||||
? `/environments/${environment.id}/settings/enterprise`
|
||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenProjectLimitModal(false),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const switcherTriggerClasses = cn(
|
||||
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus:outline-none",
|
||||
isCollapsed ? "flex items-center justify-center" : ""
|
||||
);
|
||||
|
||||
const switcherIconClasses =
|
||||
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||
|
||||
return (
|
||||
<>
|
||||
{project && (
|
||||
@@ -263,35 +509,215 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3",
|
||||
isCollapsed && "justify-center"
|
||||
)}>
|
||||
<span className={switcherIconClasses}>
|
||||
<HotelIcon 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">{project.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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_workspace")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && workspaceLoadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{workspaceLoadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setWorkspaceLoadError(null);
|
||||
setProjects([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !workspaceLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === project.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
{proj.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleProjectCreate}
|
||||
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 />
|
||||
<DropdownMenuGroup>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.workspace_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||
onClick={() => handleSettingNavigation(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="organizationDropdownTriggerSidebar"
|
||||
className={switcherTriggerClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer 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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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 && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!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 />
|
||||
<DropdownMenuGroup>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.organization_settings")}
|
||||
</div>
|
||||
{organizationSettings.map((setting) => {
|
||||
if (setting.hidden) return null;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
onClick={() => handleSettingNavigation(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
"flex w-full cursor-pointer items-center gap-3",
|
||||
isCollapsed && "justify-center"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div
|
||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
className="ph-no-capture 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-700">{t("common.account")}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||
/>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -303,8 +729,6 @@ export const MainNavigation = ({
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
@@ -318,7 +742,6 @@ export const MainNavigation = ({
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
@@ -341,6 +764,28 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
{openProjectLimitModal && (
|
||||
<ProjectLimitModal
|
||||
open={openProjectLimitModal}
|
||||
setOpen={setOpenProjectLimitModal}
|
||||
buttons={projectLimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
{openCreateProjectModal && (
|
||||
<CreateProjectModal
|
||||
open={openCreateProjectModal}
|
||||
setOpen={setOpenCreateProjectModal}
|
||||
organizationId={organization.id}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
{openCreateOrganizationModal && (
|
||||
<CreateOrganizationModal
|
||||
open={openCreateOrganizationModal}
|
||||
setOpen={setOpenCreateOrganizationModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -148,6 +148,8 @@
|
||||
"bottom_right": "Bottom Right",
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"change_organization": "Change organization",
|
||||
"change_workspace": "Change workspace",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
|
||||
@@ -1313,11 +1313,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
hobbySubscriptions.map(({ subscription }) =>
|
||||
client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
})
|
||||
)
|
||||
hobbySubscriptions.map(async ({ subscription }) => {
|
||||
try {
|
||||
await client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Stripe.errors.StripeInvalidRequestError &&
|
||||
err.statusCode === 404 &&
|
||||
err.code === "resource_missing"
|
||||
) {
|
||||
logger.warn(
|
||||
{ subscriptionId: subscription.id, organizationId },
|
||||
"Subscription already deleted, skipping cancel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ export interface ButtonProps
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
|
||||
({ className, variant, size, loading, asChild = false, disabled, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, loading, className }))}
|
||||
disabled={loading}
|
||||
ref={ref}
|
||||
{...props}>
|
||||
{...props}
|
||||
disabled={loading || disabled}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
|
||||
@@ -73,7 +73,7 @@ function Consent({
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
|
||||
@@ -83,7 +83,7 @@ function CTA({
|
||||
/>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative space-y-2" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{buttonExternal ? (
|
||||
@@ -95,7 +95,7 @@ function CTA({
|
||||
disabled={disabled}
|
||||
className="text-button font-button-weight flex items-center gap-2"
|
||||
variant={buttonVariant}
|
||||
size={"custom"}>
|
||||
size="custom">
|
||||
{buttonLabel}
|
||||
<SquareArrowOutUpRightIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -161,7 +161,7 @@ function DateElement({
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{/* Calendar - Always visible */}
|
||||
<div className="w-full">
|
||||
|
||||
@@ -292,7 +292,7 @@ function FileUpload({
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
|
||||
@@ -112,7 +112,7 @@ function FormField({
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<div className="space-y-3">
|
||||
{visibleFields.map((field) => {
|
||||
|
||||
@@ -94,7 +94,7 @@ function Matrix({
|
||||
/>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
|
||||
@@ -145,7 +145,7 @@ function DropdownVariant({
|
||||
searchPlaceholder,
|
||||
searchNoResultsText,
|
||||
}: Readonly<DropdownVariantProps>): React.JSX.Element {
|
||||
const handleOptionToggle = (optionId: string) => {
|
||||
const handleOptionToggle = (optionId: string): void => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
handleOptionRemove(optionId);
|
||||
} else {
|
||||
@@ -540,7 +540,7 @@ function MultiSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<DropdownVariant
|
||||
inputId={inputId}
|
||||
|
||||
@@ -172,7 +172,7 @@ function NPS({
|
||||
/>
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]" dir={dir}>
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
|
||||
@@ -79,7 +79,7 @@ function OpenText({
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} />
|
||||
{/* Input or Textarea */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -106,7 +106,7 @@ function PictureSelect({
|
||||
/>
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{allowMulti ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -223,7 +223,7 @@ function Ranking({
|
||||
/>
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Ranking options</legend>
|
||||
|
||||
@@ -407,7 +407,7 @@ function Rating({
|
||||
/>
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
|
||||
@@ -181,7 +181,7 @@ function SingleSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
@@ -278,7 +278,7 @@ function SingleSelect({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<RadioGroup
|
||||
name={inputId}
|
||||
|
||||
@@ -278,11 +278,12 @@ export function BlockConditional({
|
||||
if (hasValidationErrors) {
|
||||
setElementErrors(errorMap);
|
||||
|
||||
// Find the first element with an error and scroll to it
|
||||
// Find the first element with an error and scroll to its input area (not the headline)
|
||||
const firstErrorElementId = Object.keys(errorMap)[0];
|
||||
const form = elementFormRefs.current.get(firstErrorElementId);
|
||||
if (form) {
|
||||
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const scrollTarget = form.querySelector("[data-element-input]") ?? form;
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +291,8 @@ export function BlockConditional({
|
||||
// Also run legacy validation for elements not yet migrated to centralized validation
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
if (firstInvalidForm) {
|
||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const scrollTarget = firstInvalidForm.querySelector("[data-element-input]") ?? firstInvalidForm;
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TSurveyLanguage } from "./types";
|
||||
import { getTextContent } from "./validation";
|
||||
import { findLanguageCodesForDuplicateLabels, getTextContent } from "./validation";
|
||||
|
||||
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
|
||||
if (!surveyLanguages) return [];
|
||||
@@ -92,28 +92,5 @@ export const validateElementLabels = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findLanguageCodesForDuplicateLabels = (
|
||||
labels: TI18nString[],
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
): string[] => {
|
||||
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
|
||||
const languageCodes = extractLanguageCodes(enabledLanguages);
|
||||
|
||||
const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes;
|
||||
|
||||
const duplicateLabels = new Set<string>();
|
||||
|
||||
for (const language of languagesToCheck) {
|
||||
const labelTexts = labels
|
||||
.map((label) => label[language])
|
||||
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
||||
.map((text) => text.trim());
|
||||
const uniqueLabels = new Set(labelTexts);
|
||||
|
||||
if (uniqueLabels.size !== labelTexts.length) {
|
||||
duplicateLabels.add(language);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicateLabels);
|
||||
};
|
||||
// Re-export for backwards compatibility
|
||||
export { findLanguageCodesForDuplicateLabels };
|
||||
|
||||
@@ -228,7 +228,10 @@ export const findLanguageCodesForDuplicateLabels = (
|
||||
const duplicateLabels = new Set<string>();
|
||||
|
||||
for (const language of languagesToCheck) {
|
||||
const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean);
|
||||
const labelTexts = labels
|
||||
.map((label) => label[language])
|
||||
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
||||
.map((text) => text.trim());
|
||||
const uniqueLabels = new Set(labelTexts);
|
||||
|
||||
if (uniqueLabels.size !== labelTexts.length) {
|
||||
|
||||
Reference in New Issue
Block a user