mirror of
https://github.com/makeplane/plane.git
synced 2026-02-06 22:19:02 -06:00
[WEB-5860] [WEB-5861] [WEB-5862] style: improved settings interface (#8520)
* style: improved profile settings * chore: minor improvements * style: improved workspace settings * style: workspace settings content * style: improved project settings * fix: project settings flat map * chore: add back navigation from settings pages * style: settings content * style: estimates list * refactor: remove old code * refactor: removed unnecessary line breaks * refactor: create a common component for page header * chore: add fade-in animation to sidebar * fix: formatting * fix: project settings sidebar header * fix: workspace settings sidebar header * fix: settings content wrapper scroll * chore: separate project settings features * fix: formatting * refactor: custom theme selector * refactor: settings headings * refactor: settings headings * fix: project settings sidebar padding * fix: sidebar header padding * fix: sidebar item permissions * fix: missing editable check * refactor: remove unused files * chore: remove unnecessary code * chore: add missing translations * fix: formatting
This commit is contained in:
committed by
GitHub
parent
ba5ba5bf54
commit
db8b67102d
@@ -2,7 +2,6 @@ import { Outlet } from "react-router";
|
||||
// components
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
import { SettingsHeader } from "@/components/settings/header";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
@@ -10,10 +9,8 @@ export default function SettingsLayout() {
|
||||
<ProjectsAppPowerKProvider />
|
||||
<div className="relative flex size-full overflow-hidden rounded-lg border border-subtle">
|
||||
<main className="relative flex size-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
{/* Content */}
|
||||
<ContentWrapper className="p-page-x md:flex w-full bg-surface-1">
|
||||
<ContentWrapper className="md:flex w-full bg-surface-1">
|
||||
<div className="size-full overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
|
||||
export const BillingWorkspaceSettingsHeader = observer(function BillingWorkspaceSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = WORKSPACE_SETTINGS["billing-and-plans"];
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS["billing-and-plans"];
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -3,12 +3,14 @@ import { observer } from "mobx-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
// hooks
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { BillingRoot } from "@/plane-web/components/workspace/billing";
|
||||
// local imports
|
||||
import { BillingWorkspaceSettingsHeader } from "./header";
|
||||
|
||||
function BillingSettingsPage() {
|
||||
// store hooks
|
||||
@@ -23,7 +25,7 @@ function BillingSettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<SettingsContentWrapper header={<BillingWorkspaceSettingsHeader />} hugging>
|
||||
<PageHead title={pageTitle} />
|
||||
<BillingRoot />
|
||||
</SettingsContentWrapper>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
|
||||
export const ExportsWorkspaceSettingsHeader = observer(function ExportsWorkspaceSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = WORKSPACE_SETTINGS.export;
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS.export;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,17 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import ExportGuide from "@/components/exporter/guide";
|
||||
// helpers
|
||||
// hooks
|
||||
import { ExportGuide } from "@/components/exporter/guide";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import SettingsHeading from "@/components/settings/heading";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { ExportsWorkspaceSettingsHeader } from "./header";
|
||||
|
||||
function ExportsPage() {
|
||||
// store hooks
|
||||
@@ -34,10 +35,10 @@ function ExportsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<SettingsContentWrapper header={<ExportsWorkspaceSettingsHeader />} hugging>
|
||||
<PageHead title={pageTitle} />
|
||||
<div
|
||||
className={cn("w-full", {
|
||||
className={cn("w-full flex flex-col gap-y-6", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
|
||||
export const GeneralWorkspaceSettingsHeader = observer(function GeneralWorkspaceSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = WORKSPACE_SETTINGS.general;
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS.general;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import IntegrationGuide from "@/components/integration/guide";
|
||||
// hooks
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
function ImportsPage() {
|
||||
// router
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined;
|
||||
|
||||
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full">
|
||||
<SettingsHeading title="Imports" />
|
||||
<IntegrationGuide />
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ImportsPage);
|
||||
@@ -4,8 +4,7 @@ import useSWR from "swr";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SingleIntegrationCard } from "@/components/integration";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SingleIntegrationCard } from "@/components/integration/single-integration-card";
|
||||
import { IntegrationAndImportExportBanner } from "@/components/ui/integration-and-import-export-banner";
|
||||
import { IntegrationsSettingsLoader } from "@/components/ui/loader/settings/integration";
|
||||
// constants
|
||||
@@ -33,7 +32,7 @@ function WorkspaceIntegrationsPage() {
|
||||
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full overflow-y-auto">
|
||||
<IntegrationAndImportExportBanner bannerName="Integrations" />
|
||||
@@ -47,7 +46,7 @@ function WorkspaceIntegrationsPage() {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import { Outlet } from "react-router";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile/nav";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// components
|
||||
import { WorkspaceSettingsSidebarRoot } from "@/components/settings/workspace/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local components
|
||||
import { WorkspaceSettingsSidebar } from "./sidebar";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
@@ -34,18 +34,18 @@ const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav
|
||||
hamburgerContent={WorkspaceSettingsSidebar}
|
||||
hamburgerContent={WorkspaceSettingsSidebarRoot}
|
||||
activePath={getWorkspaceActivePath(pathname) || ""}
|
||||
/>
|
||||
<div className="inset-y-0 flex flex-row w-full h-full">
|
||||
{workspaceUserInfo && !isAuthorized ? (
|
||||
<NotAuthorizedView section="settings" className="h-auto" />
|
||||
) : (
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{<WorkspaceSettingsSidebar />}</div>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
<div className="relative flex size-full">
|
||||
<div className="h-full hidden md:block">
|
||||
<WorkspaceSettingsSidebarRoot />
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
|
||||
export const MembersWorkspaceSettingsHeader = observer(function MembersWorkspaceSettingsHeader() {
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = WORKSPACE_SETTINGS.members;
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS.members;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view
|
||||
import { CountChip } from "@/components/common/count-chip";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { WorkspaceMembersList } from "@/components/workspace/settings/members-list";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
@@ -22,7 +21,10 @@ import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
|
||||
import { SendWorkspaceInvitationModal, MembersActivityButton } from "@/plane-web/components/workspace/members";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { MembersWorkspaceSettingsHeader } from "./header";
|
||||
|
||||
const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) {
|
||||
// states
|
||||
@@ -93,7 +95,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<SettingsContentWrapper header={<MembersWorkspaceSettingsHeader />} hugging>
|
||||
<PageHead title={pageTitle} />
|
||||
<SendWorkspaceInvitationModal
|
||||
isOpen={inviteModal}
|
||||
@@ -101,12 +103,12 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
onSubmit={handleWorkspaceInvite}
|
||||
/>
|
||||
<section
|
||||
className={cn("w-full h-full", {
|
||||
className={cn("size-full", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-center">
|
||||
<h4 className="flex items-center gap-2.5 text-h5-medium">
|
||||
<h4 className="flex items-center gap-2.5 text-h3-medium">
|
||||
{t("workspace_settings.settings.members.title")}
|
||||
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
|
||||
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const MobileWorkspaceSettingsTabs = observer(function MobileWorkspaceSettingsTabs() {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
// mobx store
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 md:hidden sticky inset-0 flex overflow-x-auto bg-surface-1 z-10">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(item, index) =>
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), item.key) &&
|
||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<div
|
||||
className={`${
|
||||
item.highlight(pathname, `/${workspaceSlug}`)
|
||||
? "text-accent-primary text-13 py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-accent-strong-200"
|
||||
: "text-secondary flex flex-grow cursor-pointer justify-around border-b border-subtle text-13 py-2 px-3 whitespace-nowrap"
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => router.push(`/${workspaceSlug}${item.href}`)}
|
||||
>
|
||||
{t(item.i18n_label)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -7,8 +7,10 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// local imports
|
||||
import { GeneralWorkspaceSettingsHeader } from "./header";
|
||||
|
||||
function WorkspaceSettingsPage() {
|
||||
function GeneralWorkspaceSettingsPage() {
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
@@ -18,11 +20,11 @@ function WorkspaceSettingsPage() {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<GeneralWorkspaceSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceDetails />
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(WorkspaceSettingsPage);
|
||||
export default observer(GeneralWorkspaceSettingsPage);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissionsLevel,
|
||||
EUserPermissions,
|
||||
GROUPED_WORKSPACE_SETTINGS,
|
||||
WORKSPACE_SETTINGS_CATEGORIES,
|
||||
WORKSPACE_SETTINGS_CATEGORY,
|
||||
} from "@plane/constants";
|
||||
import type { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// components
|
||||
import { SettingsSidebar } from "@/components/settings/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const WORKSPACE_SETTINGS_ICONS: Record<keyof typeof WORKSPACE_SETTINGS, LucideIcon | React.FC<ISvgIcons>> = {
|
||||
general: Building,
|
||||
members: Users,
|
||||
export: ArrowUpToLine,
|
||||
"billing-and-plans": CreditCard,
|
||||
webhooks: Webhook,
|
||||
};
|
||||
|
||||
export function WorkspaceActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) {
|
||||
if (type === undefined) return null;
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} strokeWidth={2} />;
|
||||
}
|
||||
|
||||
type TWorkspaceSettingsSidebarProps = {
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export function WorkspaceSettingsSidebar(props: TWorkspaceSettingsSidebarProps) {
|
||||
const { isMobile = false } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams(); // store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
return (
|
||||
<SettingsSidebar
|
||||
isMobile={isMobile}
|
||||
categories={WORKSPACE_SETTINGS_CATEGORIES.filter(
|
||||
(category) =>
|
||||
isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category)
|
||||
)}
|
||||
groupedSettings={GROUPED_WORKSPACE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isActive={(data: { href: string }) =>
|
||||
data.href === "/settings"
|
||||
? pathname === `/${workspaceSlug}${data.href}/`
|
||||
: new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname)
|
||||
}
|
||||
shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) =>
|
||||
data.access
|
||||
? shouldRenderSettingLink(workspaceSlug.toString(), data.key) &&
|
||||
allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())
|
||||
: false
|
||||
}
|
||||
actionIcons={WorkspaceActionIcons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
|
||||
export const WebhookDetailsWorkspaceSettingsHeader = observer(function WebhookDetailsWorkspaceSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = WORKSPACE_SETTINGS.webhooks;
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS.webhooks;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,9 @@ import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/compone
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { WebhookDetailsWorkspaceSettingsHeader } from "./header";
|
||||
|
||||
function WebhookDetailsPage({ params }: Route.ComponentProps) {
|
||||
// states
|
||||
@@ -87,7 +89,7 @@ function WebhookDetailsPage({ params }: Route.ComponentProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<WebhookDetailsWorkspaceSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
|
||||
<div className="w-full space-y-8 overflow-y-auto">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
|
||||
export const WebhooksWorkspaceSettingsHeader = observer(function WebhooksWorkspaceSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = WORKSPACE_SETTINGS.webhooks;
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS.webhooks;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -4,19 +4,22 @@ import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// components
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks";
|
||||
// hooks
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { WebhooksWorkspaceSettingsHeader } from "./header";
|
||||
|
||||
function WebhooksListPage({ params }: Route.ComponentProps) {
|
||||
// states
|
||||
@@ -53,7 +56,7 @@ function WebhooksListPage({ params }: Route.ComponentProps) {
|
||||
if (!webhooks) return <WebhookSettingsLoader />;
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<WebhooksWorkspaceSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full">
|
||||
<CreateWebhookModal
|
||||
@@ -68,15 +71,14 @@ function WebhooksListPage({ params }: Route.ComponentProps) {
|
||||
<SettingsHeading
|
||||
title={t("workspace_settings.settings.webhooks.title")}
|
||||
description={t("workspace_settings.settings.webhooks.description")}
|
||||
button={{
|
||||
label: t("workspace_settings.settings.webhooks.add_webhook"),
|
||||
onClick: () => {
|
||||
setShowCreateWebhookModal(true);
|
||||
},
|
||||
}}
|
||||
control={
|
||||
<Button variant="primary" size="lg" onClick={() => setShowCreateWebhookModal(true)}>
|
||||
{t("workspace_settings.settings.webhooks.add_webhook")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{Object.keys(webhooks).length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="mt-4">
|
||||
<WebhooksList />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// component
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
|
||||
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// store hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
function ApiTokensPage() {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// router
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
|
||||
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
|
||||
: undefined;
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PageHead title={pageTitle} />
|
||||
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
|
||||
<section className="w-full">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.api_tokens.heading")}
|
||||
description={t("account_settings.api_tokens.description")}
|
||||
button={{
|
||||
label: t("workspace_settings.settings.api_tokens.add_token"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
{tokens.map((token) => (
|
||||
<ApiTokenListItem key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col py-">
|
||||
<SettingsHeading
|
||||
title={t("account_settings.api_tokens.heading")}
|
||||
description={t("account_settings.api_tokens.description")}
|
||||
button={{
|
||||
label: t("workspace_settings.settings.api_tokens.add_token"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmptyStateCompact
|
||||
assetKey="token"
|
||||
assetClassName="size-20"
|
||||
title={t("settings_empty_state.tokens.title")}
|
||||
description={t("settings_empty_state.tokens.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("settings_empty_state.tokens.cta_primary"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
rootClassName="py-20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ApiTokensPage);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { getProfileActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
// local imports
|
||||
import { ProfileSidebar } from "./sidebar";
|
||||
|
||||
function ProfileSettingsLayout() {
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProfileSidebar} activePath={getProfileActivePath(pathname) || ""} />
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">
|
||||
<ProfileSidebar />
|
||||
</div>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<SettingsContentWrapper>
|
||||
<Outlet />
|
||||
</SettingsContentWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsLayout);
|
||||
@@ -1,39 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { PreferencesList } from "@/components/preferences/list";
|
||||
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
if (!userProfile) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<PreferencesList />
|
||||
</div>
|
||||
<div>
|
||||
<ProfileSettingContentHeader title={t("language_and_time")} />
|
||||
<LanguageTimezone />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileAppearancePage;
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane imports
|
||||
import { E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
export interface FormValues {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
old_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
const defaultShowPassword = {
|
||||
oldPassword: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
};
|
||||
|
||||
function SecurityPage() {
|
||||
// store
|
||||
const { data: currentUser, changePassword } = useUser();
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(defaultShowPassword);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
|
||||
// use form
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<FormValues>({ defaultValues });
|
||||
// derived values
|
||||
const oldPassword = watch("old_password");
|
||||
const password = watch("new_password");
|
||||
const confirmPassword = watch("confirm_password");
|
||||
const oldPasswordRequired = !currentUser?.is_password_autoset;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleChangePassword = async (formData: FormValues) => {
|
||||
const { old_password, new_password } = formData;
|
||||
try {
|
||||
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
|
||||
if (!csrfToken) throw new Error("csrf token not found");
|
||||
|
||||
await changePassword(csrfToken, {
|
||||
...(oldPasswordRequired && { old_password }),
|
||||
new_password,
|
||||
});
|
||||
|
||||
reset(defaultValues);
|
||||
setShowPassword(defaultShowPassword);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("auth.common.password.toast.change_password.success.title"),
|
||||
message: t("auth.common.password.toast.change_password.success.message"),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
let errorInfo = undefined;
|
||||
if (error instanceof Error) {
|
||||
const err = error as Error & { error_code?: string };
|
||||
const code = err.error_code?.toString();
|
||||
errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
|
||||
message:
|
||||
typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
|
||||
(oldPasswordRequired && oldPassword.trim() === "") ||
|
||||
password.trim() === "" ||
|
||||
confirmPassword.trim() === "" ||
|
||||
password !== confirmPassword ||
|
||||
password === oldPassword;
|
||||
|
||||
const passwordSupport = password.length > 0 &&
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
|
||||
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
|
||||
);
|
||||
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Security" />
|
||||
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
|
||||
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
{oldPasswordRequired && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="old_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showPassword?.oldPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("old_password")}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.old_password)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.oldPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errors.old_password && (
|
||||
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="new_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="new_password"
|
||||
type={showPassword?.password ? "text" : "password"}
|
||||
value={value}
|
||||
placeholder={t("auth.common.password.new_password.placeholder")}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.new_password)}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{passwordSupport}
|
||||
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
|
||||
<span className="text-11 text-danger-primary">
|
||||
{t("new_password_must_be_different_from_old_password")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirm_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword?.confirmPassword ? "text" : "password"}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.confirm_password)}
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.confirmPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
|
||||
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
|
||||
{isSubmitting
|
||||
? `${t("auth.common.password.change_password.label.submitting")}`
|
||||
: t("auth.common.password.change_password.label.default")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SecurityPage);
|
||||
@@ -1,76 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react";
|
||||
// plane imports
|
||||
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { LockIcon } from "@plane/propel/icons";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsSidebar } from "@/components/settings/sidebar";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
const ICONS = {
|
||||
profile: CircleUser,
|
||||
security: LockIcon,
|
||||
activity: Activity,
|
||||
preferences: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": KeyRound,
|
||||
connections: Blocks,
|
||||
};
|
||||
|
||||
export function ProjectActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) {
|
||||
if (type === undefined) return null;
|
||||
const Icon = ICONS[type as keyof typeof ICONS];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} strokeWidth={2} />;
|
||||
}
|
||||
|
||||
type TProfileSidebarProps = {
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSidebarProps) {
|
||||
const { isMobile = false } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
return (
|
||||
<SettingsSidebar
|
||||
isMobile={isMobile}
|
||||
categories={PROFILE_SETTINGS_CATEGORIES}
|
||||
groupedSettings={GROUPED_PROFILE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
|
||||
customHeader={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
|
||||
<div className="h-8 w-8 rounded-full">
|
||||
<CircleUserRound className="h-full w-full text-secondary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-8 w-8 overflow-hidden">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||
alt={currentUser?.display_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="text-14 font-medium text-secondary truncate">{currentUser?.display_name}</div>
|
||||
<div className="text-13 text-tertiary truncate">{currentUser?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actionIcons={ProjectActionIcons}
|
||||
shouldRender
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const AutomationsProjectSettingsHeader = observer(function AutomationsProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.automations;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.automations;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,21 +1,22 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
// hooks
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { CustomAutomationsRoot } from "@/plane-web/components/automations/root";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { AutomationsProjectSettingsHeader } from "./header";
|
||||
|
||||
function AutomationSettingsPage({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
@@ -51,15 +52,17 @@ function AutomationSettingsPage({ params }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<SettingsContentWrapper header={<AutomationsProjectSettingsHeader />} hugging>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<SettingsHeading
|
||||
title={t("project_settings.automations.heading")}
|
||||
description={t("project_settings.automations.description")}
|
||||
/>
|
||||
<AutoArchiveAutomation handleChange={handleChange} />
|
||||
<AutoCloseAutomation handleChange={handleChange} />
|
||||
<div className="mt-6">
|
||||
<AutoArchiveAutomation handleChange={handleChange} />
|
||||
<AutoCloseAutomation handleChange={handleChange} />
|
||||
</div>
|
||||
</section>
|
||||
<CustomAutomationsRoot projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</SettingsContentWrapper>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const EstimatesProjectSettingsHeader = observer(function EstimatesProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.estimates;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.estimates;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -4,11 +4,13 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { EstimateRoot } from "@/components/estimates";
|
||||
// hooks
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { EstimatesProjectSettingsHeader } from "./header";
|
||||
|
||||
function EstimatesSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
@@ -25,7 +27,7 @@ function EstimatesSettingsPage({ params }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<EstimatesProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className={`w-full ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}>
|
||||
<EstimateRoot workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={canPerformProjectAdminActions} />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const FeaturesCyclesProjectSettingsHeader = observer(function FeaturesCyclesProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.features_cycles;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.features_cycles;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { FeaturesCyclesProjectSettingsHeader } from "./header";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
|
||||
function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name
|
||||
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.cycles.short_title")}`
|
||||
: undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper header={<FeaturesCyclesProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full">
|
||||
<SettingsHeading
|
||||
title={t("project_settings.features.cycles.title")}
|
||||
description={t("project_settings.features.cycles.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<ProjectSettingsFeatureControlItem
|
||||
title={t("project_settings.features.cycles.toggle_title")}
|
||||
description={t("project_settings.features.cycles.toggle_description")}
|
||||
featureProperty="cycle_view"
|
||||
projectId={projectId}
|
||||
value={!!currentProjectDetails?.cycle_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FeaturesCyclesSettingsPage);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const FeaturesIntakeProjectSettingsHeader = observer(function FeaturesIntakeProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.features_intake;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.features_intake;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { FeaturesIntakeProjectSettingsHeader } from "./header";
|
||||
|
||||
function FeaturesIntakeSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name
|
||||
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.intake.short_title")}`
|
||||
: undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper header={<FeaturesIntakeProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full">
|
||||
<SettingsHeading
|
||||
title={t("project_settings.features.intake.title")}
|
||||
description={t("project_settings.features.intake.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<ProjectSettingsFeatureControlItem
|
||||
title={t("project_settings.features.intake.toggle_title")}
|
||||
description={t("project_settings.features.intake.toggle_description")}
|
||||
featureProperty="inbox_view"
|
||||
projectId={projectId}
|
||||
value={!!currentProjectDetails?.inbox_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FeaturesIntakeSettingsPage);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const FeaturesModulesProjectSettingsHeader = observer(function FeaturesModulesProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.features_modules;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.features_modules;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { FeaturesModulesProjectSettingsHeader } from "./header";
|
||||
|
||||
function FeaturesModulesSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name
|
||||
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.modules.short_title")}`
|
||||
: undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper header={<FeaturesModulesProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full">
|
||||
<SettingsHeading
|
||||
title={t("project_settings.features.modules.title")}
|
||||
description={t("project_settings.features.modules.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<ProjectSettingsFeatureControlItem
|
||||
title={t("project_settings.features.modules.toggle_title")}
|
||||
description={t("project_settings.features.modules.toggle_description")}
|
||||
featureProperty="module_view"
|
||||
projectId={projectId}
|
||||
value={!!currentProjectDetails?.module_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FeaturesModulesSettingsPage);
|
||||
@@ -1,41 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
function FeaturesSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<ProjectFeaturesList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FeaturesSettingsPage);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const FeaturesPagesProjectSettingsHeader = observer(function FeaturesPagesProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.features_pages;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.features_pages;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { FeaturesPagesProjectSettingsHeader } from "./header";
|
||||
|
||||
function FeaturesPagesSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name
|
||||
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.pages.short_title")}`
|
||||
: undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper header={<FeaturesPagesProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full">
|
||||
<SettingsHeading
|
||||
title={t("project_settings.features.pages.title")}
|
||||
description={t("project_settings.features.pages.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<ProjectSettingsFeatureControlItem
|
||||
title={t("project_settings.features.pages.toggle_title")}
|
||||
description={t("project_settings.features.pages.toggle_description")}
|
||||
featureProperty="page_view"
|
||||
projectId={projectId}
|
||||
value={!!currentProjectDetails?.page_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FeaturesPagesSettingsPage);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const FeaturesViewsProjectSettingsHeader = observer(function FeaturesViewsProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.features_views;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.features_views;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { FeaturesViewsProjectSettingsHeader } from "./header";
|
||||
|
||||
function FeaturesViewsSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name
|
||||
? `${currentProjectDetails?.name} settings - ${t("project_settings.features.views.short_title")}`
|
||||
: undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper header={<FeaturesViewsProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full">
|
||||
<SettingsHeading
|
||||
title={t("project_settings.features.views.title")}
|
||||
description={t("project_settings.features.views.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<ProjectSettingsFeatureControlItem
|
||||
title={t("project_settings.features.views.toggle_title")}
|
||||
description={t("project_settings.features.views.toggle_description")}
|
||||
featureProperty="issue_views_view"
|
||||
projectId={projectId}
|
||||
value={!!currentProjectDetails?.issue_views_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FeaturesViewsSettingsPage);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const GeneralProjectSettingsHeader = observer(function GeneralProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.general;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.general;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const LabelsProjectSettingsHeader = observer(function LabelsProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.labels;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.labels;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -7,10 +7,12 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProjectSettingsLabelList } from "@/components/labels";
|
||||
// hooks
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { LabelsProjectSettingsHeader } from "./header";
|
||||
|
||||
function LabelsSettingsPage() {
|
||||
// store hooks
|
||||
@@ -45,9 +47,9 @@ function LabelsSettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<LabelsProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<div ref={scrollableContainerRef} className="h-full w-full gap-10">
|
||||
<div ref={scrollableContainerRef} className="size-full">
|
||||
<ProjectSettingsLabelList />
|
||||
</div>
|
||||
</SettingsContentWrapper>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { getProjectActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile/nav";
|
||||
// plane web imports
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// types
|
||||
import type { Route } from "./+types/layout";
|
||||
import { ProjectSettingsSidebarRoot } from "@/components/settings/project/sidebar";
|
||||
|
||||
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
@@ -17,14 +17,19 @@ function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
<SettingsMobileNav
|
||||
hamburgerContent={(props) => <ProjectSettingsSidebarRoot {...props} projectId={projectId} />}
|
||||
activePath={getProjectActivePath(pathname) || ""}
|
||||
/>
|
||||
<div className="inset-y-0 flex flex-row w-full h-full">
|
||||
<div className="relative flex size-full">
|
||||
<div className="shrink-0 h-full hidden md:block">
|
||||
<ProjectSettingsSidebarRoot projectId={projectId} />
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<Outlet />
|
||||
</ProjectAuthWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const MembersProjectSettingsHeader = observer(function MembersProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.members;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.members;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -5,17 +5,19 @@ import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
// hooks
|
||||
import { ProjectMemberList } from "@/components/project/member-list";
|
||||
import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
|
||||
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { MembersProjectSettingsHeader } from "./header";
|
||||
|
||||
function MembersSettingsPage({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
@@ -39,7 +41,7 @@ function MembersSettingsPage({ params }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper size="lg">
|
||||
<SettingsContentWrapper header={<MembersProjectSettingsHeader />} hugging>
|
||||
<PageHead title={pageTitle} />
|
||||
<SettingsHeading title={t(getProjectSettingsPageLabelI18nKey("members", "common.members"))} />
|
||||
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DeleteProjectModal } from "@/components/project/delete-project-modal";
|
||||
import { ProjectDetailsForm } from "@/components/project/form";
|
||||
import { ProjectDetailsFormLoader } from "@/components/project/form-loader";
|
||||
import { ArchiveRestoreProjectModal } from "@/components/project/settings/archive-project/archive-restore-modal";
|
||||
import { ArchiveProjectSelection } from "@/components/project/settings/archive-project/selection";
|
||||
import { DeleteProjectSection } from "@/components/project/settings/delete-project-section";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { GeneralProjectSettingsHeader } from "./header";
|
||||
import { GeneralProjectSettingsControlSection } from "@/components/project/settings/control-section";
|
||||
|
||||
function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
// states
|
||||
const [selectProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [archiveProject, setArchiveProject] = useState<boolean>(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
@@ -31,25 +26,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<GeneralProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
{currentProjectDetails && (
|
||||
<>
|
||||
<ArchiveRestoreProjectModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={archiveProject}
|
||||
onClose={() => setArchiveProject(false)}
|
||||
archive
|
||||
/>
|
||||
<DeleteProjectModal
|
||||
project={currentProjectDetails}
|
||||
isOpen={Boolean(selectProject)}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails ? (
|
||||
<ProjectDetailsForm
|
||||
@@ -61,19 +39,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
) : (
|
||||
<ProjectDetailsFormLoader />
|
||||
)}
|
||||
|
||||
{isAdmin && currentProjectDetails && (
|
||||
<>
|
||||
<ArchiveProjectSelection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleArchive={() => setArchiveProject(true)}
|
||||
/>
|
||||
<DeleteProjectSection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && <GeneralProjectSettingsControlSection projectId={projectId} />}
|
||||
</div>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_SETTINGS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SettingsPageHeader } from "@/components/settings/page-header";
|
||||
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
|
||||
|
||||
export const StatesProjectSettingsHeader = observer(function StatesProjectSettingsHeader() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const settingsDetails = PROJECT_SETTINGS.states;
|
||||
const Icon = PROJECT_SETTINGS_ICONS.states;
|
||||
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
leftItem={
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t(settingsDetails.i18n_label)}
|
||||
icon={<Icon className="size-4 text-tertiary" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -5,12 +5,14 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProjectStateRoot } from "@/components/project-states";
|
||||
// hook
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hook
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { StatesProjectSettingsHeader } from "./header";
|
||||
|
||||
function StatesSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
@@ -33,14 +35,16 @@ function StatesSettingsPage({ params }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<SettingsContentWrapper header={<StatesProjectSettingsHeader />}>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full">
|
||||
<SettingsHeading
|
||||
title={t("project_settings.states.heading")}
|
||||
description={t("project_settings.states.description")}
|
||||
/>
|
||||
<ProjectStateRoot workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
<div className="mt-6">
|
||||
<ProjectStateRoot workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,19 @@ import { Outlet } from "react-router";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
|
||||
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
|
||||
import { GlobalModals } from "@/plane-web/components/common/modal/global";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export default function WorkspaceLayout(props: Route.ComponentProps) {
|
||||
const { workspaceSlug } = props.params;
|
||||
|
||||
export default function WorkspaceLayout() {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<AppRailVisibilityProvider>
|
||||
<WorkspaceContentWrapper>
|
||||
<GlobalModals workspaceSlug={workspaceSlug} />
|
||||
<Outlet />
|
||||
</WorkspaceContentWrapper>
|
||||
</AppRailVisibilityProvider>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url";
|
||||
import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
function ProfileActivityPage() {
|
||||
// states
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset;
|
||||
|
||||
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||
|
||||
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||
|
||||
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
|
||||
|
||||
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||
|
||||
const activityPages: React.ReactNode[] = [];
|
||||
for (let i = 0; i < pageCount; i++)
|
||||
activityPages.push(
|
||||
<ProfileActivityListPage
|
||||
key={i}
|
||||
cursor={`${PER_PAGE}:${i}:0`}
|
||||
perPage={PER_PAGE}
|
||||
updateResultsCount={updateResultsCount}
|
||||
updateTotalPages={updateTotalPages}
|
||||
updateEmptyState={updateEmptyState}
|
||||
/>
|
||||
);
|
||||
|
||||
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<DetailedEmptyState
|
||||
title={t("profile.empty_state.activity.title")}
|
||||
description={t("profile.empty_state.activity.description")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Activity" />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title={t("activity")} />
|
||||
{activityPages}
|
||||
{isLoadMoreVisible && (
|
||||
<div className="flex w-full items-center justify-center text-11">
|
||||
<Button variant="secondary" onClick={handleLoadMore}>
|
||||
{t("load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileActivityPage);
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { applyCustomTheme } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
function ProfileAppearancePage() {
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
async (themeOption: I_THEME_OPTION) => {
|
||||
setTheme(themeOption.value);
|
||||
|
||||
// If switching to custom theme and user has saved custom colors, apply them immediately
|
||||
if (
|
||||
themeOption.value === "custom" &&
|
||||
userProfile?.theme?.primary &&
|
||||
userProfile?.theme?.background &&
|
||||
userProfile?.theme?.darkPalette !== undefined
|
||||
) {
|
||||
applyCustomTheme(
|
||||
userProfile.theme.primary,
|
||||
userProfile.theme.background,
|
||||
userProfile.theme.darkPalette ? "dark" : "light"
|
||||
);
|
||||
}
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Theme updated",
|
||||
message: () => "Reloading to apply changes...",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to update theme. Please try again.",
|
||||
},
|
||||
});
|
||||
// Wait for the promise to resolve, then reload after showing toast
|
||||
try {
|
||||
await updateCurrentUserThemePromise;
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
// Error toast already shown by setPromiseToast
|
||||
console.error("Error updating theme:", error);
|
||||
}
|
||||
},
|
||||
[setTheme, updateUserTheme, userProfile]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Appearance" />
|
||||
{userProfile ? (
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title={t("appearance")} />
|
||||
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-16 font-semibold text-primary">{t("theme")}</h4>
|
||||
<p className="text-13 text-secondary">{t("select_or_customize_your_interface_color_scheme")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</ProfileSettingContentWrapper>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileAppearancePage);
|
||||
@@ -1,37 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
import { EmailSettingsLoader } from "@/components/ui/loader/settings/email";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export default function ProfileNotificationPage() {
|
||||
const { t } = useTranslation();
|
||||
// fetching user email notification settings
|
||||
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
|
||||
userService.currentUserEmailNotificationSettings()
|
||||
);
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <EmailSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader
|
||||
title={t("email_notifications")}
|
||||
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
|
||||
/>
|
||||
<EmailNotificationForm data={data} />
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileForm } from "@/components/profile/form";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
function ProfileSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileForm user={currentUser} profile={userProfile.data} />
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsPage);
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
// icons
|
||||
import { LogOut, MoveLeft, Activity, Bell, CircleUser, KeyRound, Settings2, CirclePlus, Mails } from "lucide-react";
|
||||
// plane imports
|
||||
import { PROFILE_ACTION_LINKS } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ChevronLeftIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserSettings } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const WORKSPACE_ACTION_LINKS = [
|
||||
{
|
||||
key: "create_workspace",
|
||||
Icon: CirclePlus,
|
||||
i18n_label: "create_workspace",
|
||||
href: "/create-workspace",
|
||||
},
|
||||
{
|
||||
key: "invitations",
|
||||
Icon: Mails,
|
||||
i18n_label: "workspace_invites",
|
||||
href: "/invitations",
|
||||
},
|
||||
];
|
||||
|
||||
function ProjectActionIcons({ type, size, className = "" }: { type: string; size?: number; className?: string }) {
|
||||
const icons = {
|
||||
profile: CircleUser,
|
||||
security: KeyRound,
|
||||
activity: Activity,
|
||||
preferences: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": KeyRound,
|
||||
};
|
||||
|
||||
if (type === undefined) return null;
|
||||
const Icon = icons[type as keyof typeof icons];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} />;
|
||||
}
|
||||
|
||||
export const ProfileLayoutSidebar = observer(function ProfileLayoutSidebar() {
|
||||
// states
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
||||
const { data: currentUser, signOut } = useUser();
|
||||
const { data: currentUserSettings } = useUserSettings();
|
||||
const { workspaces } = useWorkspace();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const workspacesList = Object.values(workspaces ?? {});
|
||||
|
||||
// redirect url for normal mode
|
||||
const redirectWorkspaceSlug =
|
||||
currentUserSettings?.workspace?.last_workspace_slug ||
|
||||
currentUserSettings?.workspace?.fallback_workspace_slug ||
|
||||
"";
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClickDetector(ref, () => {
|
||||
if (sidebarCollapsed === false) {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
toggleSidebar(true);
|
||||
}
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [toggleSidebar]);
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
await signOut()
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
message: t("sign_out.toast.error.message"),
|
||||
})
|
||||
)
|
||||
.finally(() => setIsSigningOut(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative
|
||||
${sidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
sm:${sidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
md:ml-0 ${sidebarCollapsed ? "w-[70px]" : "w-[250px]"}
|
||||
`}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-col gap-y-4">
|
||||
<Link href={`/${redirectWorkspaceSlug}`} onClick={handleItemClick}>
|
||||
<div
|
||||
className={`flex flex-shrink-0 items-center gap-2 truncate px-4 pt-4 ${
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
|
||||
<ChevronLeftIcon className="h-5 w-5" strokeWidth={1} />
|
||||
</span>
|
||||
{!sidebarCollapsed && (
|
||||
<h4 className="truncate text-16 font-semibold text-secondary">{t("profile_settings")}</h4>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-shrink-0 flex-col overflow-x-hidden">
|
||||
{!sidebarCollapsed && (
|
||||
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("your_account")}</h6>
|
||||
)}
|
||||
<div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto">
|
||||
{PROFILE_ACTION_LINKS.map((link) => {
|
||||
if (link.key === "change-password" && currentUser?.is_password_autoset) return null;
|
||||
|
||||
return (
|
||||
<Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}>
|
||||
<Tooltip
|
||||
tooltipContent={t(link.key)}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
|
||||
isActive={link.highlight(pathname)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<ProjectActionIcons type={link.key} size={16} />
|
||||
|
||||
{!sidebarCollapsed && <p className="text-13 leading-5 font-medium">{t(link.i18n_label)}</p>}
|
||||
</div>
|
||||
</SidebarNavItem>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-x-hidden">
|
||||
{!sidebarCollapsed && (
|
||||
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("workspaces")}</h6>
|
||||
)}
|
||||
{workspacesList && workspacesList.length > 0 && (
|
||||
<div
|
||||
className={cn("vertical-scrollbar scrollbar-xs mt-2 px-4 h-full space-y-1.5 overflow-y-auto", {
|
||||
"scrollbar-sm": !sidebarCollapsed,
|
||||
"ml-2.5 px-1": sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
{workspacesList.map((workspace) => (
|
||||
<Link
|
||||
key={workspace.id}
|
||||
href={`/${workspace.slug}`}
|
||||
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-13 font-medium ${
|
||||
sidebarCollapsed ? "justify-center" : `justify-between`
|
||||
}`}
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<span
|
||||
className={`flex w-full flex-grow items-center gap-x-2 truncate rounded-md px-3 py-1 hover:bg-layer-1 ${
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
|
||||
!workspace?.logo_url && "rounded-sm bg-accent-primary text-on-color"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
(workspace?.name?.charAt(0) ?? "...")
|
||||
)}
|
||||
</span>
|
||||
{!sidebarCollapsed && <p className="truncate text-13 text-secondary">{workspace.name}</p>}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1.5 px-4">
|
||||
{WORKSPACE_ACTION_LINKS.map((link) => (
|
||||
<Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}>
|
||||
<Tooltip
|
||||
tooltipContent={t(link.key)}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-13 font-medium text-secondary outline-none hover:bg-layer-1 focus:bg-layer-1 ${
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
{<link.Icon className="flex-shrink-0 size-4" />}
|
||||
{!sidebarCollapsed && t(link.i18n_label)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-grow items-end px-6 py-2">
|
||||
<div
|
||||
className={`flex w-full ${
|
||||
sidebarCollapsed ? "flex-col justify-center gap-2" : "items-center justify-between gap-2"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center justify-center gap-2 text-13 font-medium text-danger-primary"
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{!sidebarCollapsed && <span>{isSigningOut ? `${t("signing_out")}...` : t("sign_out")}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:hidden"
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto hidden place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:grid ${
|
||||
sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
55
apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx
Normal file
55
apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROFILE_SETTINGS_TABS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingsContent } from "@/components/settings/profile/content";
|
||||
import { ProfileSettingsSidebarRoot } from "@/components/settings/profile/sidebar";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import type { Route } from "../+types/layout";
|
||||
|
||||
function ProfileSettingsPage(props: Route.ComponentProps) {
|
||||
const { profileTabId } = props.params;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const isAValidTab = PROFILE_SETTINGS_TABS.includes(profileTabId as TProfileSettingsTabs);
|
||||
|
||||
if (!currentUser || !isAValidTab)
|
||||
return (
|
||||
<div className="size-full grid place-items-center px-4">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<div className="relative size-full">
|
||||
<div className="size-full flex">
|
||||
<ProfileSettingsSidebarRoot
|
||||
activeTab={profileTabId as TProfileSettingsTabs}
|
||||
className="w-[250px]"
|
||||
updateActiveTab={(tab) => router.push(`/settings/profile/${tab}`)}
|
||||
/>
|
||||
<ProfileSettingsContent
|
||||
activeTab={profileTabId as TProfileSettingsTabs}
|
||||
className="grow py-20 px-page-x mx-auto w-fit max-w-225"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsPage);
|
||||
@@ -1,20 +1,17 @@
|
||||
// components
|
||||
import { Outlet } from "react-router";
|
||||
// wrappers
|
||||
// components
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
// lib
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
// layout
|
||||
import { ProfileLayoutSidebar } from "./sidebar";
|
||||
|
||||
export default function ProfileSettingsLayout() {
|
||||
return (
|
||||
<>
|
||||
<ProjectsAppPowerKProvider />
|
||||
<AuthenticationWrapper>
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-subtle">
|
||||
<ProfileLayoutSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="relative flex size-full overflow-hidden bg-canvas p-2">
|
||||
<main className="relative flex flex-col size-full overflow-hidden bg-surface-1 rounded-lg border border-subtle">
|
||||
<div className="size-full overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
@@ -278,34 +278,6 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// ACCOUNT SETTINGS
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx", [
|
||||
route(":workspaceSlug/settings/account", "./(all)/[workspaceSlug]/(settings)/settings/account/page.tsx"),
|
||||
route(
|
||||
":workspaceSlug/settings/account/activity",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/preferences",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/notifications",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/security",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/api-tokens",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// PROJECT SETTINGS
|
||||
// --------------------------------------------------------------------
|
||||
@@ -326,8 +298,24 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
),
|
||||
// Project Features
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
|
||||
":workspaceSlug/settings/projects/:projectId/features/cycles",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features/modules",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features/views",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features/pages",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features/intake",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx"
|
||||
),
|
||||
// Project States
|
||||
route(
|
||||
@@ -363,12 +351,8 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
// PROFILE SETTINGS
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
layout("./(all)/profile/layout.tsx", [
|
||||
route("profile", "./(all)/profile/page.tsx"),
|
||||
route("profile/activity", "./(all)/profile/activity/page.tsx"),
|
||||
route("profile/appearance", "./(all)/profile/appearance/page.tsx"),
|
||||
route("profile/notifications", "./(all)/profile/notifications/page.tsx"),
|
||||
route("profile/security", "./(all)/profile/security/page.tsx"),
|
||||
layout("./(all)/settings/profile/layout.tsx", [
|
||||
route("settings/profile/:profileTabId", "./(all)/settings/profile/[profileTabId]/page.tsx"),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -389,7 +373,7 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"),
|
||||
|
||||
// API tokens redirect: /:workspaceSlug/settings/api-tokens
|
||||
// → /:workspaceSlug/settings/account/api-tokens
|
||||
// → /settings/profile/api-tokens
|
||||
route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"),
|
||||
|
||||
// Inbox redirect: /:workspaceSlug/projects/:projectId/inbox
|
||||
@@ -406,4 +390,10 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
|
||||
// Register redirect
|
||||
route("register", "routes/redirects/core/register.tsx"),
|
||||
|
||||
// Profile settings redirects
|
||||
route("profile/*", "routes/redirects/core/profile-settings.tsx"),
|
||||
|
||||
// Account settings redirects
|
||||
route(":workspaceSlug/settings/account/*", "routes/redirects/core/workspace-account-settings.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/api-tokens";
|
||||
|
||||
export const clientLoader = ({ params }: Route.ClientLoaderArgs) => {
|
||||
const { workspaceSlug } = params;
|
||||
throw redirect(`/${workspaceSlug}/settings/account/api-tokens/`);
|
||||
export const clientLoader = () => {
|
||||
throw redirect(`/settings/profile/api-tokens/`);
|
||||
};
|
||||
|
||||
export default function ApiTokens() {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const coreRedirectRoutes: RouteConfigEntry[] = [
|
||||
route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"),
|
||||
|
||||
// API tokens redirect: /:workspaceSlug/settings/api-tokens
|
||||
// → /:workspaceSlug/settings/account/api-tokens
|
||||
// → /settings/profile/api-tokens
|
||||
route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"),
|
||||
|
||||
// Inbox redirect: /:workspaceSlug/projects/:projectId/inbox
|
||||
|
||||
12
apps/web/app/routes/redirects/core/profile-settings.tsx
Normal file
12
apps/web/app/routes/redirects/core/profile-settings.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/profile-settings";
|
||||
|
||||
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const splat = params["*"] || "";
|
||||
throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
export default function ProfileSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/workspace-account-settings";
|
||||
|
||||
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const splat = params["*"] || "";
|
||||
throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
export default function WorkspaceAccountSettings() {
|
||||
return null;
|
||||
}
|
||||
26
apps/web/ce/components/common/modal/global.tsx
Normal file
26
apps/web/ce/components/common/modal/global.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
const ProfileSettingsModal = lazy(() =>
|
||||
import("@/components/settings/profile/modal").then((module) => ({
|
||||
default: module.ProfileSettingsModal,
|
||||
}))
|
||||
);
|
||||
|
||||
type TGlobalModalsProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalModals component manages all workspace-level modals across Plane applications.
|
||||
*
|
||||
* This includes:
|
||||
* - Profile settings modal
|
||||
*/
|
||||
export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ProfileSettingsModal />
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
||||
<HelpMenuRoot />
|
||||
<StarUsOnGitHubLink />
|
||||
<div className="flex items-center justify-center size-8 hover:bg-layer-1-hover rounded-md">
|
||||
<UserMenuRoot size="xs" />
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
|
||||
import { ThemeSwitcher } from "./theme-switcher";
|
||||
|
||||
export const PREFERENCE_COMPONENTS = {
|
||||
theme: ThemeSwitcher,
|
||||
start_of_week: StartOfWeekPreference,
|
||||
};
|
||||
@@ -10,8 +10,7 @@ import { applyCustomTheme } from "@plane/utils";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
// helpers
|
||||
import { PreferencesSection } from "@/components/preferences/section";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
@@ -79,18 +78,16 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesSection
|
||||
<SettingsControlItem
|
||||
title={t(props.option.title)}
|
||||
description={t(props.option.description)}
|
||||
control={
|
||||
<div>
|
||||
<ThemeSwitch
|
||||
value={currentTheme}
|
||||
onChange={(themeOption) => {
|
||||
void handleThemeChange(themeOption);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ThemeSwitch
|
||||
value={currentTheme}
|
||||
onChange={(themeOption) => {
|
||||
void handleThemeChange(themeOption);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "@plane/i18n";
|
||||
import type { TBillingFrequency, TProductBillingFrequency } from "@plane/types";
|
||||
import { EProductSubscriptionEnum } from "@plane/types";
|
||||
// components
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// local imports
|
||||
import { PlansComparison } from "./comparison/root";
|
||||
@@ -37,32 +38,28 @@ export const BillingRoot = observer(function BillingRoot() {
|
||||
setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency });
|
||||
|
||||
return (
|
||||
<section className="relative size-full flex flex-col overflow-y-auto scrollbar-hide">
|
||||
<SettingsHeading
|
||||
title={t("workspace_settings.settings.billing_and_plans.heading")}
|
||||
description={t("workspace_settings.settings.billing_and_plans.description")}
|
||||
/>
|
||||
<section className="relative size-full overflow-y-auto scrollbar-hide">
|
||||
<div>
|
||||
<div className="py-6">
|
||||
<div className="px-6 py-4 rounded-lg bg-layer-1">
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-h4-bold text-primary">Community</h4>
|
||||
<div className="text-caption-md-medium text-secondary">
|
||||
Unlimited projects, issues, cycles, modules, pages, and storage
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsHeading
|
||||
title={t("workspace_settings.settings.billing_and_plans.heading")}
|
||||
description={t("workspace_settings.settings.billing_and_plans.description")}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<SettingsBoxedControlItem
|
||||
title="Community"
|
||||
description="Unlimited projects, issues, cycles, modules, pages, and storage"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-h4-semibold mt-3">All plans</div>
|
||||
</div>
|
||||
<PlansComparison
|
||||
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
|
||||
getBillingFrequency={getBillingFrequency}
|
||||
setBillingFrequency={setBillingFrequency}
|
||||
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
|
||||
/>
|
||||
<div className="mt-10 flex flex-col gap-y-3">
|
||||
<h4 className="text-h6-semibold">All plans</h4>
|
||||
<PlansComparison
|
||||
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
|
||||
getBillingFrequency={getBillingFrequency}
|
||||
setBillingFrequency={setBillingFrequency}
|
||||
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
// plane imports
|
||||
import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// ui
|
||||
import { Collapsible } from "@plane/ui";
|
||||
import { DeleteWorkspaceModal } from "./delete-workspace-modal";
|
||||
// components
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
// local imports
|
||||
import { DeleteWorkspaceModal } from "./delete-workspace-modal";
|
||||
|
||||
type TDeleteWorkspace = {
|
||||
workspace: IWorkspace | null;
|
||||
@@ -18,8 +17,8 @@ type TDeleteWorkspace = {
|
||||
export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(props: TDeleteWorkspace) {
|
||||
const { workspace } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -29,40 +28,19 @@ export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(p
|
||||
isOpen={deleteWorkspaceModal}
|
||||
onClose={() => setDeleteWorkspaceModal(false)}
|
||||
/>
|
||||
<div className="border-t border-subtle">
|
||||
<div className="w-full">
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen(!isOpen)}
|
||||
className="w-full"
|
||||
buttonClassName="flex w-full items-center justify-between py-4"
|
||||
title={
|
||||
<>
|
||||
<span className="text-body-md-medium tracking-tight">
|
||||
{t("workspace_settings.settings.general.delete_workspace")}
|
||||
</span>
|
||||
{isOpen ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
</>
|
||||
}
|
||||
<SettingsBoxedControlItem
|
||||
title={t("workspace_settings.settings.general.delete_workspace")}
|
||||
description={t("workspace_settings.settings.general.delete_workspace_description")}
|
||||
control={
|
||||
<Button
|
||||
variant="error-outline"
|
||||
onClick={() => setDeleteWorkspaceModal(true)}
|
||||
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.DELETE_WORKSPACE_BUTTON}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-body-sm-regular tracking-tight">
|
||||
{t("workspace_settings.settings.general.delete_workspace_description")}
|
||||
</span>
|
||||
<div>
|
||||
<Button
|
||||
variant="error-fill"
|
||||
size="lg"
|
||||
onClick={() => setDeleteWorkspaceModal(true)}
|
||||
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.DELETE_WORKSPACE_BUTTON}
|
||||
>
|
||||
{t("workspace_settings.settings.general.delete_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./features";
|
||||
export * from "./tabs";
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// icons
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import { SettingIcon } from "@/components/icons/attachment";
|
||||
// types
|
||||
import type { Props } from "@/components/icons/types";
|
||||
// constants
|
||||
|
||||
export const PROJECT_SETTINGS = {
|
||||
general: {
|
||||
key: "general",
|
||||
i18n_label: "common.general",
|
||||
href: ``,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
members: {
|
||||
key: "members",
|
||||
i18n_label: "common.members",
|
||||
href: `/members`,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
features: {
|
||||
key: "features",
|
||||
i18n_label: "common.features",
|
||||
href: `/features`,
|
||||
access: [EUserPermissions.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
states: {
|
||||
key: "states",
|
||||
i18n_label: "common.states",
|
||||
href: `/states`,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
labels: {
|
||||
key: "labels",
|
||||
i18n_label: "common.labels",
|
||||
href: `/labels`,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
estimates: {
|
||||
key: "estimates",
|
||||
i18n_label: "common.estimates",
|
||||
href: `/estimates`,
|
||||
access: [EUserPermissions.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
automations: {
|
||||
key: "automations",
|
||||
i18n_label: "project_settings.automations.label",
|
||||
href: `/automations`,
|
||||
access: [EUserPermissions.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const PROJECT_SETTINGS_LINKS: {
|
||||
key: string;
|
||||
i18n_label: string;
|
||||
href: string;
|
||||
access: EUserPermissions[];
|
||||
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||
Icon: React.FC<Props>;
|
||||
}[] = [
|
||||
PROJECT_SETTINGS["general"],
|
||||
PROJECT_SETTINGS["members"],
|
||||
PROJECT_SETTINGS["features"],
|
||||
PROJECT_SETTINGS["states"],
|
||||
PROJECT_SETTINGS["labels"],
|
||||
PROJECT_SETTINGS["estimates"],
|
||||
PROJECT_SETTINGS["automations"],
|
||||
];
|
||||
1
apps/web/core/components/appearance/index.ts
Normal file
1
apps/web/core/components/appearance/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./theme-switcher";
|
||||
70
apps/web/core/components/appearance/theme-switcher.tsx
Normal file
70
apps/web/core/components/appearance/theme-switcher.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
option: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}) {
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(themeOption: I_THEME_OPTION) => {
|
||||
try {
|
||||
setTheme(themeOption.value);
|
||||
const updatePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updatePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to update the theme",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating theme:", error);
|
||||
}
|
||||
},
|
||||
[updateUserTheme]
|
||||
);
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsControlItem
|
||||
title={t(props.option.title)}
|
||||
description={t(props.option.description)}
|
||||
control={<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -2,14 +2,14 @@ import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArchiveRestore } from "lucide-react";
|
||||
// types
|
||||
// plane imports
|
||||
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
|
||||
// component
|
||||
import { SelectMonthModal } from "@/components/automation";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
@@ -61,25 +61,22 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 border-b border-subtle py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded-sm bg-layer-3 p-3">
|
||||
<ArchiveRestore className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-13 font-medium">{t("project_settings.automations.auto-archive.title")}</h4>
|
||||
<p className="text-13 tracking-tight text-tertiary">
|
||||
{t("project_settings.automations.auto-archive.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 border-b border-subtle py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0 size-10 grid place-items-center rounded-sm bg-layer-2">
|
||||
<ArchiveRestore className="shrink-0 size-4 text-primary" />
|
||||
</div>
|
||||
<ToggleSwitch value={autoArchiveStatus} onChange={handleToggleArchive} size="sm" disabled={!isAdmin} />
|
||||
<SettingsControlItem
|
||||
title={t("project_settings.automations.auto-archive.title")}
|
||||
description={t("project_settings.automations.auto-archive.description")}
|
||||
control={
|
||||
<ToggleSwitch value={autoArchiveStatus} onChange={handleToggleArchive} size="sm" disabled={!isAdmin} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentProjectDetails ? (
|
||||
autoArchiveStatus && (
|
||||
<div className="mx-6">
|
||||
<div className="ml-13">
|
||||
<div className="flex w-full items-center justify-between gap-2 rounded-sm border border-subtle bg-surface-2 px-5 py-4">
|
||||
<div className="w-1/2 text-13 font-medium">
|
||||
{t("project_settings.automations.auto-archive.duration")}
|
||||
@@ -90,9 +87,7 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
|
||||
label={`${currentProjectDetails?.archive_in} ${
|
||||
currentProjectDetails?.archive_in === 1 ? "month" : "months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
onChange={(val: number) => void handleChange({ archive_in: val })}
|
||||
input
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
@@ -117,7 +112,7 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mx-6">
|
||||
<Loader className="ml-13">
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ArchiveX } from "lucide-react";
|
||||
// types
|
||||
// plane imports
|
||||
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel, EIconSize } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { StateGroupIcon, StatePropertyIcon } from "@plane/propel/icons";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, CustomSearchSelect, ToggleSwitch, Loader } from "@plane/ui";
|
||||
// component
|
||||
import { SelectMonthModal } from "@/components/automation";
|
||||
// constants
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
@@ -82,36 +79,34 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props:
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded-sm bg-layer-3 p-3">
|
||||
<ArchiveX className="h-4 w-4 flex-shrink-0 text-danger-primary" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-13 font-medium">{t("project_settings.automations.auto-close.title")}</h4>
|
||||
<p className="text-13 tracking-tight text-tertiary">
|
||||
{t("project_settings.automations.auto-close.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0 size-10 grid place-items-center rounded-sm bg-layer-2">
|
||||
<ArchiveX className="shrink-0 size-4 text-danger-primary" />
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={autoCloseStatus}
|
||||
onChange={async () => {
|
||||
if (currentProjectDetails?.close_in === 0) {
|
||||
await handleChange({ close_in: 1, default_state: defaultState });
|
||||
} else {
|
||||
await handleChange({ close_in: 0, default_state: null });
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={!isAdmin}
|
||||
<SettingsControlItem
|
||||
title={t("project_settings.automations.auto-close.title")}
|
||||
description={t("project_settings.automations.auto-close.description")}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
value={autoCloseStatus}
|
||||
onChange={() => {
|
||||
if (currentProjectDetails?.close_in === 0) {
|
||||
void handleChange({ close_in: 1, default_state: defaultState });
|
||||
} else {
|
||||
void handleChange({ close_in: 0, default_state: null });
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentProjectDetails ? (
|
||||
autoCloseStatus && (
|
||||
<div className="mx-6">
|
||||
<div className="ml-13">
|
||||
<div className="flex flex-col rounded-sm border border-subtle bg-surface-2">
|
||||
<div className="flex w-full items-center justify-between gap-2 px-5 py-4">
|
||||
<div className="w-1/2 text-13 font-medium">
|
||||
@@ -123,9 +118,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props:
|
||||
label={`${currentProjectDetails?.close_in} ${
|
||||
currentProjectDetails?.close_in === 1 ? "month" : "months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
onChange={(val: number) => void handleChange({ close_in: val })}
|
||||
input
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
@@ -176,9 +169,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props:
|
||||
: (currentDefaultState?.name ?? <span className="text-secondary">{t("state")}</span>)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
onChange={(val: string) => void handleChange({ default_state: val })}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
input
|
||||
@@ -189,7 +180,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props:
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mx-6">
|
||||
<Loader className="ml-13">
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
|
||||
92
apps/web/core/components/core/theme/color-inputs.tsx
Normal file
92
apps/web/core/components/core/theme/color-inputs.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// plane imports
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
import { InputColorPicker } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
control: Control<IUserTheme>;
|
||||
};
|
||||
|
||||
export const CustomThemeColorInputs = observer(function CustomThemeColorInputs(props: Props) {
|
||||
const { control } = props;
|
||||
|
||||
const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => {
|
||||
let hex = val;
|
||||
// prepend a hashtag if it doesn't exist
|
||||
if (val && val[0] !== "#") hex = `#${val}`;
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Neutral Color */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-body-sm-medium">
|
||||
Neutral color<span className="text-danger-primary">*</span>
|
||||
</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
rules={{
|
||||
required: "Neutral color is required",
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="background"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#1a1a1a"
|
||||
className="w-full placeholder:text-placeholder"
|
||||
style={{
|
||||
backgroundColor: value,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
hasError={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Brand Color */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-body-sm-medium">
|
||||
Brand color<span className="text-danger-primary">*</span>
|
||||
</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="primary"
|
||||
rules={{
|
||||
required: "Brand color is required",
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="primary"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#3f76ff"
|
||||
className="w-full placeholder:text-placeholder"
|
||||
style={{
|
||||
backgroundColor: value,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
hasError={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
import { InputColorPicker, ToggleSwitch } from "@plane/ui";
|
||||
import { applyCustomTheme } from "@plane/utils";
|
||||
// components
|
||||
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { CustomThemeConfigHandler } from "./config-handler";
|
||||
import { CustomThemeColorInputs } from "./color-inputs";
|
||||
import { CustomThemeDownloadConfigButton } from "./download-config-button";
|
||||
import { CustomThemeImportConfigButton } from "./import-config-button";
|
||||
import { CustomThemeModeSelector } from "./theme-mode-selector";
|
||||
|
||||
export const CustomThemeSelector = observer(function CustomThemeSelector() {
|
||||
// store hooks
|
||||
@@ -23,18 +27,17 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() {
|
||||
const [isLoadingPalette, setIsLoadingPalette] = useState(false);
|
||||
|
||||
// Load saved theme from userProfile (fallback to defaults)
|
||||
const getSavedTheme = (): IUserTheme => {
|
||||
if (userProfile?.theme) {
|
||||
const theme = userProfile.theme;
|
||||
if (theme.primary && theme.background && theme.darkPalette !== undefined) {
|
||||
return {
|
||||
theme: "custom",
|
||||
primary: theme.primary,
|
||||
background: theme.background,
|
||||
darkPalette: theme.darkPalette,
|
||||
};
|
||||
}
|
||||
const savedTheme = useMemo((): IUserTheme => {
|
||||
const theme = userProfile?.theme;
|
||||
if (theme && theme.primary && theme.background) {
|
||||
return {
|
||||
theme: "custom",
|
||||
primary: theme.primary,
|
||||
background: theme.background,
|
||||
darkPalette: !!theme.darkPalette,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to defaults
|
||||
return {
|
||||
theme: "custom",
|
||||
@@ -42,21 +45,20 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() {
|
||||
background: "#1a1a1a",
|
||||
darkPalette: false,
|
||||
};
|
||||
};
|
||||
}, [userProfile?.theme]);
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
getValues,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IUserTheme>({
|
||||
defaultValues: getSavedTheme(),
|
||||
defaultValues: savedTheme,
|
||||
});
|
||||
|
||||
const handleUpdateTheme = async (formData: IUserTheme) => {
|
||||
if (!formData.primary || !formData.background || formData.darkPalette === undefined) return;
|
||||
if (!formData.primary || !formData.background) return;
|
||||
|
||||
try {
|
||||
setIsLoadingPalette(true);
|
||||
@@ -90,109 +92,29 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => {
|
||||
let hex = val;
|
||||
// prepend a hashtag if it doesn't exist
|
||||
if (val && val[0] !== "#") hex = `#${val}`;
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(handleUpdateTheme)(e);
|
||||
}}
|
||||
className="bg-layer-1 border border-subtle rounded-lg py-3 px-4"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-16 font-semibold text-primary">{t("customize_your_theme")}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Color Inputs */}
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||
{/* Brand Color */}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Brand color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="primary"
|
||||
rules={{
|
||||
required: "Brand color is required",
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="primary"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#3f76ff"
|
||||
className="w-full placeholder:text-placeholder"
|
||||
style={{
|
||||
backgroundColor: value,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
hasError={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neutral Color */}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Neutral color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
rules={{
|
||||
required: "Neutral color is required",
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="background"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#1a1a1a"
|
||||
className="w-full placeholder:text-placeholder"
|
||||
style={{
|
||||
backgroundColor: value,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
hasError={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileSettingsHeading
|
||||
title={t("customize_your_theme")}
|
||||
control={<CustomThemeImportConfigButton handleUpdateTheme={handleUpdateTheme} setValue={setValue} />}
|
||||
/>
|
||||
<CustomThemeModeSelector control={control} />
|
||||
{/* Color Inputs */}
|
||||
<CustomThemeColorInputs control={control} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
{/* Save Theme Button */}
|
||||
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
|
||||
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating" : t("set_theme")}
|
||||
</Button>
|
||||
{/* Import/Export Section */}
|
||||
<CustomThemeConfigHandler getValues={getValues} handleUpdateTheme={handleUpdateTheme} setValue={setValue} />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Theme Mode Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="darkPalette"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
|
||||
)}
|
||||
/>
|
||||
<span className="text-12 text-tertiary">{watch("darkPalette") ? "Dark mode" : "Light mode"}</span>
|
||||
</div>
|
||||
{/* Save Theme Button */}
|
||||
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
|
||||
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating..." : t("set_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
<CustomThemeDownloadConfigButton getValues={getValues} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { observer } from "mobx-react";
|
||||
import type { UseFormGetValues } from "react-hook-form";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
getValues: UseFormGetValues<IUserTheme>;
|
||||
};
|
||||
|
||||
export const CustomThemeDownloadConfigButton = observer(function CustomThemeDownloadConfigButton(props: Props) {
|
||||
const { getValues } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
try {
|
||||
const currentValues = getValues();
|
||||
const config = {
|
||||
version: "1.0",
|
||||
themeName: "Custom Theme",
|
||||
primary: currentValues.primary,
|
||||
background: currentValues.background,
|
||||
darkPalette: currentValues.darkPalette,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `plane-theme-${Date.now()}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: "Theme configuration downloaded successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to download config:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: "Failed to download theme configuration.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" size="lg" type="button" onClick={handleDownloadConfig}>
|
||||
Download config
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { UseFormGetValues, UseFormSetValue } from "react-hook-form";
|
||||
import type { UseFormSetValue } from "react-hook-form";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
@@ -8,54 +8,17 @@ import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
getValues: UseFormGetValues<IUserTheme>;
|
||||
handleUpdateTheme: (formData: IUserTheme) => Promise<void>;
|
||||
setValue: UseFormSetValue<IUserTheme>;
|
||||
};
|
||||
|
||||
export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) {
|
||||
const { getValues, handleUpdateTheme, setValue } = props;
|
||||
export const CustomThemeImportConfigButton = observer(function CustomThemeImportConfigButton(props: Props) {
|
||||
const { handleUpdateTheme, setValue } = props;
|
||||
// refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
try {
|
||||
const currentValues = getValues();
|
||||
const config = {
|
||||
version: "1.0",
|
||||
themeName: "Custom Theme",
|
||||
primary: currentValues.primary,
|
||||
background: currentValues.background,
|
||||
darkPalette: currentValues.darkPalette,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `plane-theme-${Date.now()}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: "Theme configuration downloaded successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to download config:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: "Failed to download theme configuration.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -122,14 +85,11 @@ export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandl
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<>
|
||||
<input ref={fileInputRef} type="file" accept=".json" onChange={handleUploadConfig} className="hidden" />
|
||||
<Button variant="secondary" type="button" onClick={() => fileInputRef.current?.click()}>
|
||||
<Button variant="secondary" size="lg" type="button" onClick={() => fileInputRef.current?.click()}>
|
||||
Import config
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" onClick={handleDownloadConfig}>
|
||||
Download config
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
51
apps/web/core/components/core/theme/theme-mode-selector.tsx
Normal file
51
apps/web/core/components/core/theme/theme-mode-selector.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// plane imports
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IUserTheme>;
|
||||
};
|
||||
|
||||
export const CustomThemeModeSelector = observer(function CustomThemeModeSelector(props: Props) {
|
||||
const { control } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h6 className="text-h6-medium">
|
||||
Choose color mode<span className="text-danger-primary">*</span>
|
||||
</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="darkPalette"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<label className="bg-layer-2 hover:bg-layer-2-hover border border-subtle-1 rounded-lg py-2 px-3 flex items-center gap-1.5 text-body-sm-regular cursor-pointer transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="darkPalette"
|
||||
value="false"
|
||||
checked={value === false}
|
||||
onChange={() => onChange(false)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Light mode
|
||||
</label>
|
||||
<label className="bg-layer-2 hover:bg-layer-2-hover border border-subtle-1 rounded-lg py-2 px-3 flex items-center gap-1.5 text-body-sm-regular cursor-pointer transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="darkPalette"
|
||||
value="true"
|
||||
checked={value === true}
|
||||
onChange={() => onChange(true)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Dark mode
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export function ThemeSwitch(props: Props) {
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
buttonClassName="border border-subtle-1"
|
||||
placement="bottom-end"
|
||||
input
|
||||
>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EEstimateSystem } from "@plane/constants";
|
||||
import { convertMinutesToHoursMinutesString, cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { convertMinutesToHoursMinutesString } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
|
||||
// plane web components
|
||||
// plane web imports
|
||||
import { EstimateListItemButtons } from "@/plane-web/components/estimates";
|
||||
|
||||
type TEstimateListItem = {
|
||||
@@ -19,40 +20,31 @@ type TEstimateListItem = {
|
||||
};
|
||||
|
||||
export const EstimateListItem = observer(function EstimateListItem(props: TEstimateListItem) {
|
||||
const { estimateId, isAdmin, isEstimateEnabled, isEditable } = props;
|
||||
// hooks
|
||||
const { estimateId } = props;
|
||||
// store hooks
|
||||
const { estimateById } = useProjectEstimates();
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(estimateId);
|
||||
const currentEstimate = estimateById(estimateId);
|
||||
|
||||
// derived values
|
||||
const estimatePointValues = estimatePointIds?.map((estimatePointId) => {
|
||||
const estimatePoint = estimatePointById(estimatePointId);
|
||||
if (estimatePoint) return estimatePoint.value;
|
||||
});
|
||||
|
||||
if (!currentEstimate) return <></>;
|
||||
if (!currentEstimate) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-b border-subtle flex justify-between items-center gap-3 py-3.5",
|
||||
isAdmin && isEditable && isEstimateEnabled ? `text-primary` : `text-secondary`
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-14">{currentEstimate?.name}</h3>
|
||||
<p className="text-11">
|
||||
{estimatePointValues
|
||||
?.map((estimatePointValue) => {
|
||||
if (currentEstimate?.type === EEstimateSystem.TIME) {
|
||||
return convertMinutesToHoursMinutesString(Number(estimatePointValue));
|
||||
}
|
||||
return estimatePointValue;
|
||||
})
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<EstimateListItemButtons {...props} />
|
||||
</div>
|
||||
<SettingsBoxedControlItem
|
||||
title={currentEstimate.name}
|
||||
description={estimatePointValues
|
||||
?.map((estimatePointValue) => {
|
||||
if (currentEstimate.type === EEstimateSystem.TIME) {
|
||||
return convertMinutesToHoursMinutesString(Number(estimatePointValue));
|
||||
}
|
||||
return estimatePointValue;
|
||||
})
|
||||
.join(", ")}
|
||||
control={<EstimateListItemButtons {...props} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
@@ -11,7 +13,6 @@ import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web components
|
||||
import { UpdateEstimateModal } from "@/plane-web/components/estimates";
|
||||
// local imports
|
||||
import { SettingsHeading } from "../settings/heading";
|
||||
import { CreateEstimateModal } from "./create/modal";
|
||||
import { DeleteEstimateModal } from "./delete/modal";
|
||||
import { EstimateDisableSwitch } from "./estimate-disable-switch";
|
||||
@@ -41,40 +42,43 @@ export const EstimateRoot = observer(function EstimateRoot(props: TEstimateRoot)
|
||||
async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId)
|
||||
);
|
||||
|
||||
if (loader === "init-loader" || isSWRLoading) {
|
||||
return <EstimateLoaderScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
{loader === "init-loader" || isSWRLoading ? (
|
||||
<EstimateLoaderScreen />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* header */}
|
||||
|
||||
<SettingsHeading
|
||||
title={t("project_settings.estimates.heading")}
|
||||
description={t("project_settings.estimates.description")}
|
||||
/>
|
||||
|
||||
<>
|
||||
<div>
|
||||
{/* header */}
|
||||
<SettingsHeading
|
||||
title={t("project_settings.estimates.heading")}
|
||||
description={t("project_settings.estimates.description")}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
{/* current active estimate section */}
|
||||
{currentActiveEstimateId ? (
|
||||
<div className="">
|
||||
<>
|
||||
{/* estimates activated deactivated section */}
|
||||
<div className="relative border-b border-subtle pb-4 flex justify-between items-center gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-16 font-medium text-primary">{t("project_settings.estimates.title")}</h3>
|
||||
<p className="text-13 text-secondary">{t("project_settings.estimates.enable_description")}</p>
|
||||
</div>
|
||||
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
|
||||
</div>
|
||||
{/* active estimates section */}
|
||||
<EstimateList
|
||||
estimateIds={[currentActiveEstimateId]}
|
||||
isAdmin={isAdmin}
|
||||
isEstimateEnabled={Boolean(currentProjectDetails?.estimate)}
|
||||
isEditable
|
||||
onEditClick={(estimateId: string) => setEstimateToUpdate(estimateId)}
|
||||
onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)}
|
||||
<SettingsBoxedControlItem
|
||||
title={t("project_settings.estimates.title")}
|
||||
description={t("project_settings.estimates.enable_description")}
|
||||
control={
|
||||
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* active estimates section */}
|
||||
<div className="mt-12 flex flex-col gap-y-4">
|
||||
<SettingsHeading title="Estimates list" variant="h6" />
|
||||
<EstimateList
|
||||
estimateIds={[currentActiveEstimateId]}
|
||||
isAdmin={isAdmin}
|
||||
isEstimateEnabled={Boolean(currentProjectDetails?.estimate)}
|
||||
isEditable
|
||||
onEditClick={(estimateId: string) => setEstimateToUpdate(estimateId)}
|
||||
onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyStateCompact
|
||||
assetKey="estimate"
|
||||
@@ -91,31 +95,32 @@ export const EstimateRoot = observer(function EstimateRoot(props: TEstimateRoot)
|
||||
rootClassName="py-20"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* archived estimates section */}
|
||||
{archivedEstimateIds && archivedEstimateIds.length > 0 && (
|
||||
<div className="">
|
||||
<div className="border-b border-subtle space-y-1 pb-4">
|
||||
<h3 className="text-16 font-medium text-primary">Archived estimates</h3>
|
||||
<p className="text-13 text-secondary">
|
||||
Estimates have gone through a change, these are the estimates you had in your older versions which
|
||||
were not in use. Read more about them
|
||||
<a
|
||||
href={"https://docs.plane.so/core-concepts/projects/run-project#estimate"}
|
||||
target="_blank"
|
||||
className="text-accent-primary/80 hover:text-accent-primary"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-col gap-y-4">
|
||||
<SettingsHeading
|
||||
title="Archived estimates"
|
||||
description={
|
||||
<>
|
||||
Estimates have gone through a change, these are the estimates you had in your older versions which
|
||||
were not in use. Read more about them
|
||||
<a
|
||||
href={"https://docs.plane.so/core-concepts/projects/run-project#estimate"}
|
||||
target="_blank"
|
||||
className="text-accent-primary/80 hover:text-accent-primary"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here.
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
variant="h6"
|
||||
/>
|
||||
<EstimateList estimateIds={archivedEstimateIds} isAdmin={isAdmin} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{/* CRUD modals */}
|
||||
<CreateEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
@@ -137,6 +142,6 @@ export const EstimateRoot = observer(function EstimateRoot(props: TEstimateRoot)
|
||||
isOpen={estimateToDelete ? true : false}
|
||||
handleClose={() => setEstimateToDelete(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CustomSearchSelect, CustomSelect } from "@plane/ui";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { ProjectExportService } from "@/services/project/project-export.service";
|
||||
// local imports
|
||||
import { SettingsBoxedControlItem } from "../settings/boxed-control-item";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -134,68 +136,75 @@ export const ExportForm = observer(function ExportForm(props: Props) {
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(ExportCSVToMail)(e);
|
||||
}}
|
||||
className="flex flex-col gap-4 mt-4"
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="rounded-lg border border-subtle bg-layer-2">
|
||||
{/* Project Selector */}
|
||||
<div className="w-1/2">
|
||||
<div className="text-13 font-medium text-secondary mb-2">
|
||||
{t("workspace_settings.settings.exports.exporting_projects")}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
disabled={!isMember && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
input
|
||||
label={
|
||||
value && value.length > 0
|
||||
? value
|
||||
.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
<SettingsBoxedControlItem
|
||||
className="rounded-none border-0 border-b"
|
||||
title={t("workspace_settings.settings.exports.exporting_projects")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
disabled={!isMember && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
input
|
||||
label={
|
||||
value && value.length > 0
|
||||
? value
|
||||
.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
return projectDetails?.identifier;
|
||||
})
|
||||
.join(", ")
|
||||
: "All projects"
|
||||
}
|
||||
optionsClassName="max-w-48 sm:max-w-[532px]"
|
||||
placement="bottom-end"
|
||||
multiple
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
return projectDetails?.identifier;
|
||||
})
|
||||
.join(", ")
|
||||
: "All projects"
|
||||
}
|
||||
optionsClassName="max-w-48 sm:max-w-[532px]"
|
||||
placement="bottom-end"
|
||||
multiple
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Format Selector */}
|
||||
<div className="w-1/2">
|
||||
<div className="text-13 font-medium text-secondary mb-2">
|
||||
{t("workspace_settings.settings.exports.format")}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider"
|
||||
disabled={!isMember && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={t(value.i18n_title)}
|
||||
optionsClassName="max-w-48 sm:max-w-[532px]"
|
||||
placement="bottom-end"
|
||||
buttonClassName="py-2 text-13"
|
||||
>
|
||||
{EXPORTERS_LIST.map((service) => (
|
||||
<CustomSelect.Option key={service.provider} className="flex items-center gap-2" value={service}>
|
||||
<span className="truncate">{t(service.i18n_title)}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
<SettingsBoxedControlItem
|
||||
className="rounded-none border-0 border-b"
|
||||
title={t("workspace_settings.settings.exports.format")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider"
|
||||
disabled={!isMember && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={t(value.i18n_title)}
|
||||
optionsClassName="max-w-48 sm:max-w-[532px]"
|
||||
placement="bottom-end"
|
||||
buttonClassName="py-2 text-13"
|
||||
>
|
||||
{EXPORTERS_LIST.map((service) => (
|
||||
<CustomSelect.Option key={service.provider} className="flex items-center gap-2" value={service}>
|
||||
<span className="truncate">{t(service.i18n_title)}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="px-4 py-3">
|
||||
<Button variant="primary" size="lg" type="submit" loading={exportLoading}>
|
||||
{exportLoading ? `${t("workspace_settings.settings.exports.exporting")}...` : t("export")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rich Filters */}
|
||||
@@ -241,11 +250,6 @@ export const ExportForm = observer(function ExportForm(props: Props) {
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="primary" type="submit" loading={exportLoading}>
|
||||
{exportLoading ? `${t("workspace_settings.settings.exports.exporting")}...` : t("export")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { mutate } from "swr";
|
||||
// constants
|
||||
import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys";
|
||||
// local imports
|
||||
import { ExportForm } from "./export-form";
|
||||
import { PrevExports } from "./prev-exports";
|
||||
|
||||
const IntegrationGuide = observer(function IntegrationGuide() {
|
||||
export const ExportGuide = observer(function ExportGuide() {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -17,18 +19,14 @@ const IntegrationGuide = observer(function IntegrationGuide() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
<>
|
||||
<ExportForm
|
||||
workspaceSlug={workspaceSlug}
|
||||
provider={provider}
|
||||
mutateServices={() => mutate(EXPORT_SERVICES_LIST(workspaceSlug, `${cursor}`, `${per_page}`))}
|
||||
/>
|
||||
<PrevExports workspaceSlug={workspaceSlug} cursor={cursor} per_page={per_page} setCursor={setCursor} />
|
||||
</>
|
||||
<div className="size-full flex flex-col gap-y-13">
|
||||
<ExportForm
|
||||
workspaceSlug={workspaceSlug}
|
||||
provider={provider}
|
||||
mutateServices={() => mutate(EXPORT_SERVICES_LIST(workspaceSlug, `${cursor}`, `${per_page}`))}
|
||||
/>
|
||||
<PrevExports workspaceSlug={workspaceSlug} cursor={cursor} per_page={per_page} setCursor={setCursor} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IntegrationGuide;
|
||||
|
||||
@@ -59,11 +59,9 @@ export const PrevExports = observer(function PrevExports(props: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-subtle pb-3.5 pt-7">
|
||||
<div className="flex items-center justify-between border-b border-subtle pb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex gap-2 text-18 font-medium">
|
||||
{t("workspace_settings.settings.exports.previous_exports")}
|
||||
</h3>
|
||||
<h3 className="text-h6-medium text-primary">{t("workspace_settings.settings.exports.previous_exports")}</h3>
|
||||
<Button variant="tertiary" className="shrink-0" onClick={handleRefresh}>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />
|
||||
{refreshing ? t("refreshing") : t("refresh_status")}
|
||||
@@ -92,7 +90,6 @@ export const PrevExports = observer(function PrevExports(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{exporterServices && exporterServices?.results ? (
|
||||
exporterServices?.results?.length > 0 ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
@@ -38,13 +38,14 @@ export const TimezoneSelect = observer(function TimezoneSelect(props: TTimezoneS
|
||||
label={value && selectedValue ? selectedValue(value) : label}
|
||||
options={isDisabled || disabled ? [] : timezones}
|
||||
onChange={onChange}
|
||||
buttonClassName={cn(buttonClassName, {
|
||||
buttonClassName={cn(buttonClassName, "border border-subtle-1", {
|
||||
"border-danger-strong": error,
|
||||
})}
|
||||
className={cn("rounded-md border-[0.5px] !border-subtle", className)}
|
||||
className={cn("rounded-md", className)}
|
||||
optionsClassName={cn("w-72", optionsClassName)}
|
||||
input
|
||||
disabled={isDisabled || disabled}
|
||||
placement="bottom-end"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ export const NoProjectsEmptyState = observer(function NoProjectsEmptyState() {
|
||||
flag: "visited_profile",
|
||||
cta: {
|
||||
text: "home.empty.personalize_account.cta",
|
||||
link: `/${workspaceSlug}/settings/account`,
|
||||
link: `/settings/profile/general`,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { mutate } from "swr";
|
||||
// icons
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// services
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUser, IImporterService } from "@plane/types";
|
||||
import { Input, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys";
|
||||
import { IntegrationService } from "@/services/integrations/integration.service";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data: IImporterService | null;
|
||||
user: IUser | null;
|
||||
};
|
||||
|
||||
// services
|
||||
const integrationService = new IntegrationService();
|
||||
|
||||
export function DeleteImportModal({ isOpen, handleClose, data }: Props) {
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [confirmDeleteImport, setConfirmDeleteImport] = useState(false);
|
||||
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const handleDeletion = () => {
|
||||
if (!workspaceSlug || !data) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
|
||||
mutate<IImporterService[]>(
|
||||
IMPORTER_SERVICES_LIST(workspaceSlug),
|
||||
(prevData) => (prevData ?? []).filter((i) => i.id !== data.id),
|
||||
false
|
||||
);
|
||||
|
||||
integrationService
|
||||
.deleteImporterService(workspaceSlug, data.service, data.id)
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => {
|
||||
setDeleteLoading(false);
|
||||
handleClose();
|
||||
});
|
||||
};
|
||||
|
||||
if (!data) return <></>;
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex w-full items-center justify-start gap-6">
|
||||
<span className="place-items-center rounded-full bg-danger-subtle p-4">
|
||||
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="flex items-center justify-start">
|
||||
<h3 className="text-18 font-medium 2xl:text-20">Delete project</h3>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<p className="text-13 leading-7 text-secondary">
|
||||
Are you sure you want to delete import from{" "}
|
||||
<span className="break-words font-semibold capitalize text-primary">{data?.service}</span>? All of the data
|
||||
related to the import will be permanently removed. This action cannot be undone.
|
||||
</p>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-13 text-secondary">
|
||||
To confirm, type <span className="font-medium text-primary">delete import</span> below:
|
||||
</p>
|
||||
<Input
|
||||
id="typeDelete"
|
||||
type="text"
|
||||
name="typeDelete"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "delete import") setConfirmDeleteImport(true);
|
||||
else setConfirmDeleteImport(false);
|
||||
}}
|
||||
placeholder="Enter 'delete import'"
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="error-fill"
|
||||
size="lg"
|
||||
tabIndex={1}
|
||||
onClick={handleDeletion}
|
||||
disabled={!confirmDeleteImport}
|
||||
loading={deleteLoading}
|
||||
>
|
||||
{deleteLoading ? "Deleting..." : "Delete Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IWorkspaceIntegration } from "@plane/types";
|
||||
// ui
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import useIntegrationPopup from "@/hooks/use-integration-popup";
|
||||
|
||||
type Props = {
|
||||
workspaceIntegration: false | IWorkspaceIntegration | undefined;
|
||||
provider: string | undefined;
|
||||
};
|
||||
|
||||
export const GithubAuth = observer(function GithubAuth({ workspaceIntegration, provider }: Props) {
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
// hooks
|
||||
const { startAuth, isConnecting } = useIntegrationPopup({
|
||||
provider,
|
||||
github_app_name: config?.github_app_name || "",
|
||||
slack_client_id: config?.slack_client_id || "",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{workspaceIntegration && workspaceIntegration?.id ? (
|
||||
<Button variant="primary" disabled>
|
||||
Successfully Connected
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={startAuth} loading={isConnecting}>
|
||||
{isConnecting ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
// components
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IAppIntegration, IWorkspaceIntegration } from "@plane/types";
|
||||
import type { TIntegrationSteps } from "@/components/integration";
|
||||
import { GithubAuth } from "@/components/integration";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
provider: string | undefined;
|
||||
handleStepChange: (value: TIntegrationSteps) => void;
|
||||
appIntegrations: IAppIntegration[] | undefined;
|
||||
workspaceIntegrations: IWorkspaceIntegration[] | undefined;
|
||||
};
|
||||
|
||||
export function GithubImportConfigure({ handleStepChange, provider, appIntegrations, workspaceIntegrations }: Props) {
|
||||
// current integration from all the integrations available
|
||||
const integration =
|
||||
appIntegrations && appIntegrations.length > 0 && appIntegrations.find((i) => i.provider === provider);
|
||||
|
||||
// current integration from workspace integrations
|
||||
const workspaceIntegration =
|
||||
integration &&
|
||||
workspaceIntegrations &&
|
||||
workspaceIntegrations.length > 0 &&
|
||||
workspaceIntegrations.find((i: any) => i.integration_detail.id === integration.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 py-5">
|
||||
<div className="w-full">
|
||||
<div className="font-medium">Configure</div>
|
||||
<div className="text-13 text-secondary">Set up your GitHub import.</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<GithubAuth workspaceIntegration={workspaceIntegration} provider={provider} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleStepChange("import-data")}
|
||||
disabled={workspaceIntegration && workspaceIntegration?.id ? false : true}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import type { UseFormWatch } from "react-hook-form";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
// types
|
||||
import type { TFormValues, TIntegrationSteps } from "@/components/integration";
|
||||
|
||||
type Props = {
|
||||
handleStepChange: (value: TIntegrationSteps) => void;
|
||||
watch: UseFormWatch<TFormValues>;
|
||||
};
|
||||
|
||||
export function GithubImportConfirm({ handleStepChange, watch }: Props) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-secondary">
|
||||
You are about to import work items from {watch("github").full_name}. Click on {'"'}Confirm & Import{'" '}
|
||||
to complete the process.
|
||||
</h4>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button variant="secondary" onClick={() => handleStepChange("import-users")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="primary" type="submit">
|
||||
Confirm & Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control, UseFormWatch } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IWorkspaceIntegration } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { CustomSearchSelect, ToggleSwitch } from "@plane/ui";
|
||||
import { truncateText } from "@plane/utils";
|
||||
import type { TFormValues, TIntegrationSteps } from "@/components/integration";
|
||||
import { SelectRepository } from "@/components/integration";
|
||||
// ui
|
||||
// helpers
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
handleStepChange: (value: TIntegrationSteps) => void;
|
||||
integration: IWorkspaceIntegration | false | undefined;
|
||||
control: Control<TFormValues, any>;
|
||||
watch: UseFormWatch<TFormValues>;
|
||||
};
|
||||
|
||||
export const GithubImportData = observer(function GithubImportData(props: Props) {
|
||||
const { handleStepChange, integration, control, watch } = props;
|
||||
// store hooks
|
||||
const { workspaceProjectIds, getProjectById } = useProject();
|
||||
|
||||
const options = workspaceProjectIds?.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
return {
|
||||
value: `${projectDetails?.id}`,
|
||||
query: `${projectDetails?.name}`,
|
||||
content: <p>{truncateText(projectDetails?.name ?? "", 25)}</p>,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-8">
|
||||
<h4 className="font-semibold">Select Repository</h4>
|
||||
<p className="text-11 text-secondary">
|
||||
Select the repository that you want the work items to be imported from.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-4">
|
||||
{integration && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="github"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectRepository
|
||||
integration={integration}
|
||||
value={value ? value.id : null}
|
||||
label={value ? `${value.full_name}` : <span className="text-secondary">Select Repository</span>}
|
||||
onChange={onChange}
|
||||
characterLimit={50}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-8">
|
||||
<h4 className="font-semibold">Select Project</h4>
|
||||
<p className="text-11 text-secondary">Select the project to import the work item to.</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-4">
|
||||
{workspaceProjectIds && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
label={value ? getProjectById(value)?.name : <span className="text-secondary">Select Project</span>}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-8">
|
||||
<h4 className="font-semibold">Sync work item</h4>
|
||||
<p className="text-11 text-secondary">Set whether you want to sync the work items or not.</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="sync"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch value={value} onChange={() => onChange(!value)} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => handleStepChange("import-configure")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleStepChange("repo-details")}
|
||||
disabled={!watch("github") || !watch("project")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import type { UseFormWatch } from "react-hook-form";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
// types
|
||||
import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration";
|
||||
import { SingleUserSelect } from "@/components/integration";
|
||||
|
||||
type Props = {
|
||||
handleStepChange: (value: TIntegrationSteps) => void;
|
||||
users: IUserDetails[];
|
||||
setUsers: React.Dispatch<React.SetStateAction<IUserDetails[]>>;
|
||||
watch: UseFormWatch<TFormValues>;
|
||||
};
|
||||
|
||||
export function GithubImportUsers({ handleStepChange, users, setUsers, watch }: Props) {
|
||||
const isInvalid = users.filter((u) => u.import !== false && u.email === "").length > 0;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div>
|
||||
<div className="mb-2 grid grid-cols-3 gap-2 text-13 font-medium">
|
||||
<div className="text-secondary">Name</div>
|
||||
<div className="text-secondary">Import as...</div>
|
||||
<div className="text-right">{users.filter((u) => u.import !== false).length} users selected</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{watch("collaborators").map((collaborator, index) => (
|
||||
<SingleUserSelect
|
||||
key={collaborator.id}
|
||||
collaborator={collaborator}
|
||||
index={index}
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => handleStepChange("repo-details")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => handleStepChange("import-confirm")} disabled={isInvalid}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export * from "./auth";
|
||||
export * from "./import-configure";
|
||||
export * from "./import-confirm";
|
||||
export * from "./import-data";
|
||||
export * from "./import-users";
|
||||
export * from "./repo-details";
|
||||
export * from "./root";
|
||||
export * from "./select-repository";
|
||||
export * from "./single-user-select";
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
// react-hook-form
|
||||
import type { UseFormSetValue } from "react-hook-form";
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration";
|
||||
// fetch-keys
|
||||
import { GITHUB_REPOSITORY_INFO } from "@/constants/fetch-keys";
|
||||
import { GithubIntegrationService } from "@/services/integrations";
|
||||
|
||||
type Props = {
|
||||
selectedRepo: any;
|
||||
handleStepChange: (value: TIntegrationSteps) => void;
|
||||
setUsers: React.Dispatch<React.SetStateAction<IUserDetails[]>>;
|
||||
setValue: UseFormSetValue<TFormValues>;
|
||||
};
|
||||
|
||||
// services
|
||||
const githubIntegrationService = new GithubIntegrationService();
|
||||
|
||||
export function GithubRepoDetails({ selectedRepo, handleStepChange, setUsers, setValue }: Props) {
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const { data: repoInfo } = useSWR(
|
||||
workspaceSlug && selectedRepo ? GITHUB_REPOSITORY_INFO(workspaceSlug, selectedRepo.name) : null,
|
||||
workspaceSlug && selectedRepo
|
||||
? () =>
|
||||
githubIntegrationService.getGithubRepoInfo(workspaceSlug, {
|
||||
owner: selectedRepo.owner.login,
|
||||
repo: selectedRepo.name,
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoInfo) return;
|
||||
|
||||
setValue("collaborators", repoInfo.collaborators);
|
||||
|
||||
const fetchedUsers = repoInfo.collaborators.map((collaborator) => ({
|
||||
username: collaborator.login,
|
||||
import: "map",
|
||||
email: "",
|
||||
}));
|
||||
setUsers(fetchedUsers);
|
||||
}, [repoInfo, setUsers, setValue]);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{repoInfo ? (
|
||||
repoInfo.issue_count > 0 ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-medium">Repository Details</div>
|
||||
<div className="text-13 text-secondary">Import completed. We have found:</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-16">
|
||||
<div className="flex-shrink-0 text-center">
|
||||
<p className="text-24 font-bold">{repoInfo.issue_count}</p>
|
||||
<h6 className="text-13 text-secondary">Work items</h6>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-center">
|
||||
<p className="text-24 font-bold">{repoInfo.labels}</p>
|
||||
<h6 className="text-13 text-secondary">Labels</h6>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-center">
|
||||
<p className="text-24 font-bold">{repoInfo.collaborators.length}</p>
|
||||
<h6 className="text-13 text-secondary">Users</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h5>We didn{"'"}t find any work item in this repository.</h5>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="70px" />
|
||||
</Loader>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => handleStepChange("import-data")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleStepChange("import-users")}
|
||||
disabled={!repoInfo || repoInfo.issue_count === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { ArrowLeft, List, Settings, UploadCloud } from "lucide-react";
|
||||
import { CheckIcon, MembersPropertyIcon } from "@plane/propel/icons";
|
||||
// types
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types";
|
||||
// assets
|
||||
import GithubLogo from "@/app/assets/services/github.png?url";
|
||||
// components
|
||||
import {
|
||||
GithubImportConfigure,
|
||||
GithubImportData,
|
||||
GithubRepoDetails,
|
||||
GithubImportUsers,
|
||||
GithubImportConfirm,
|
||||
} from "@/components/integration";
|
||||
// fetch keys
|
||||
import { APP_INTEGRATIONS, IMPORTER_SERVICES_LIST, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { IntegrationService, GithubIntegrationService } from "@/services/integrations";
|
||||
|
||||
export type TIntegrationSteps = "import-configure" | "import-data" | "repo-details" | "import-users" | "import-confirm";
|
||||
export interface IIntegrationData {
|
||||
state: TIntegrationSteps;
|
||||
}
|
||||
|
||||
export interface IUserDetails {
|
||||
username: string;
|
||||
import: any;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type TFormValues = {
|
||||
github: any;
|
||||
project: string | null;
|
||||
sync: boolean;
|
||||
collaborators: IGithubRepoCollaborator[];
|
||||
users: IUserDetails[];
|
||||
};
|
||||
|
||||
const defaultFormValues = {
|
||||
github: null,
|
||||
project: null,
|
||||
sync: false,
|
||||
};
|
||||
|
||||
const integrationWorkflowData = [
|
||||
{
|
||||
title: "Configure",
|
||||
key: "import-configure",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: "Import Data",
|
||||
key: "import-data",
|
||||
icon: UploadCloud,
|
||||
},
|
||||
{ title: "Work item", key: "repo-details", icon: List },
|
||||
{
|
||||
title: "Users",
|
||||
key: "import-users",
|
||||
icon: MembersPropertyIcon,
|
||||
},
|
||||
{
|
||||
title: "Confirm",
|
||||
key: "import-confirm",
|
||||
icon: CheckIcon,
|
||||
},
|
||||
];
|
||||
|
||||
// services
|
||||
const integrationService = new IntegrationService();
|
||||
const githubIntegrationService = new GithubIntegrationService();
|
||||
|
||||
export function GithubImporterRoot() {
|
||||
const [currentStep, setCurrentStep] = useState<IIntegrationData>({
|
||||
state: "import-configure",
|
||||
});
|
||||
const [users, setUsers] = useState<IUserDetails[]>([]);
|
||||
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const provider = searchParams.get("provider");
|
||||
|
||||
const { handleSubmit, control, setValue, watch } = useForm<TFormValues>({
|
||||
defaultValues: defaultFormValues,
|
||||
});
|
||||
|
||||
const { data: appIntegrations } = useSWR(APP_INTEGRATIONS, () => integrationService.getAppIntegrationsList());
|
||||
|
||||
const { data: workspaceIntegrations } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug) : null,
|
||||
workspaceSlug ? () => integrationService.getWorkspaceIntegrationsList(workspaceSlug) : null
|
||||
);
|
||||
|
||||
const activeIntegrationState = () => {
|
||||
const currentElementIndex = integrationWorkflowData.findIndex((i) => i?.key === currentStep?.state);
|
||||
|
||||
return currentElementIndex;
|
||||
};
|
||||
|
||||
const handleStepChange = (value: TIntegrationSteps) => {
|
||||
setCurrentStep((prevData) => ({ ...prevData, state: value }));
|
||||
};
|
||||
|
||||
// current integration from all the integrations available
|
||||
const integration =
|
||||
appIntegrations && appIntegrations.length > 0 && appIntegrations.find((i) => i.provider === provider);
|
||||
|
||||
// current integration from workspace integrations
|
||||
const workspaceIntegration =
|
||||
integration && workspaceIntegrations?.find((i: any) => i.integration_detail.id === integration.id);
|
||||
|
||||
const createGithubImporterService = async (formData: TFormValues) => {
|
||||
if (!formData.github || !formData.project) return;
|
||||
|
||||
const payload: IGithubServiceImportFormData = {
|
||||
metadata: {
|
||||
owner: formData.github.owner.login,
|
||||
name: formData.github.name,
|
||||
repository_id: formData.github.id,
|
||||
url: formData.github.html_url,
|
||||
},
|
||||
data: {
|
||||
users: users,
|
||||
},
|
||||
config: {
|
||||
sync: formData.sync,
|
||||
},
|
||||
project_id: formData.project,
|
||||
};
|
||||
|
||||
await githubIntegrationService
|
||||
.createGithubServiceImport(workspaceSlug, payload)
|
||||
.then(() => {
|
||||
router.push(`/${workspaceSlug}/settings/imports`);
|
||||
mutate(IMPORTER_SERVICES_LIST(workspaceSlug));
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Import was unsuccessful. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createGithubImporterService)}>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Link href={`/${workspaceSlug}/settings/imports`}>
|
||||
<span className="inline-flex cursor-pointer items-center gap-2 text-13 font-medium text-secondary hover:text-primary">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
<div>Cancel import & go back</div>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-4 rounded-[10px] border border-subtle bg-surface-1 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 flex-shrink-0">
|
||||
<img src={GithubLogo} className="w-full h-full object-cover" alt="GitHubLogo" />
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{integrationWorkflowData.map((integration, index) => (
|
||||
<React.Fragment key={integration.key}>
|
||||
<div
|
||||
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border ${
|
||||
index <= activeIntegrationState()
|
||||
? `border-accent-strong ${
|
||||
index === activeIntegrationState()
|
||||
? "border-opacity-100 bg-accent-primary"
|
||||
: "border-opacity-80 bg-accent-primary/80"
|
||||
}`
|
||||
: "border-subtle"
|
||||
}`}
|
||||
>
|
||||
<integration.icon
|
||||
className={`h-5 w-5 ${index <= activeIntegrationState() ? "text-on-color" : "text-placeholder"}`}
|
||||
/>
|
||||
</div>
|
||||
{index < integrationWorkflowData.length - 1 && (
|
||||
<div
|
||||
key={index}
|
||||
className={`border-b px-7 ${
|
||||
index <= activeIntegrationState() - 1 ? `border-accent-strong` : `border-subtle`
|
||||
}`}
|
||||
>
|
||||
{" "}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full space-y-4">
|
||||
<div className="w-full">
|
||||
{currentStep?.state === "import-configure" && (
|
||||
<GithubImportConfigure
|
||||
handleStepChange={handleStepChange}
|
||||
provider={provider as string}
|
||||
appIntegrations={appIntegrations}
|
||||
workspaceIntegrations={workspaceIntegrations}
|
||||
/>
|
||||
)}
|
||||
{currentStep?.state === "import-data" && (
|
||||
<GithubImportData
|
||||
handleStepChange={handleStepChange}
|
||||
integration={workspaceIntegration}
|
||||
control={control}
|
||||
watch={watch}
|
||||
/>
|
||||
)}
|
||||
{currentStep?.state === "repo-details" && (
|
||||
<GithubRepoDetails
|
||||
selectedRepo={watch("github")}
|
||||
handleStepChange={handleStepChange}
|
||||
setUsers={setUsers}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
{currentStep?.state === "import-users" && (
|
||||
<GithubImportUsers
|
||||
handleStepChange={handleStepChange}
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
watch={watch}
|
||||
/>
|
||||
)}
|
||||
{currentStep?.state === "import-confirm" && (
|
||||
<GithubImportConfirm handleStepChange={handleStepChange} watch={watch} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane types
|
||||
import type { IGithubRepoCollaborator } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui";
|
||||
// constants
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// types
|
||||
import type { IUserDetails } from "./root";
|
||||
|
||||
type Props = {
|
||||
collaborator: IGithubRepoCollaborator;
|
||||
index: number;
|
||||
users: IUserDetails[];
|
||||
setUsers: React.Dispatch<React.SetStateAction<IUserDetails[]>>;
|
||||
};
|
||||
|
||||
const importOptions = [
|
||||
{
|
||||
key: "map",
|
||||
label: "Map to existing",
|
||||
},
|
||||
{
|
||||
key: "invite",
|
||||
label: "Invite by email",
|
||||
},
|
||||
{
|
||||
key: false,
|
||||
label: "Do not import",
|
||||
},
|
||||
];
|
||||
|
||||
// services
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export function SingleUserSelect({ collaborator, index, users, setUsers }: Props) {
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug.toString()) : null,
|
||||
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
const options = members
|
||||
?.map((member) => {
|
||||
if (!member?.member) return;
|
||||
return {
|
||||
value: member.member?.display_name,
|
||||
query: member.member?.display_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={member?.member?.display_name} src={getFileURL(member?.member?.avatar_url)} />
|
||||
{member.member?.display_name}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((member) => !!member) as
|
||||
| {
|
||||
value: string;
|
||||
query: string;
|
||||
content: React.ReactNode;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 items-center gap-2 rounded-md bg-layer-1 px-2 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-8 w-8 flex-shrink-0 rounded-sm">
|
||||
<img
|
||||
src={collaborator.avatar_url}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
|
||||
alt={`${collaborator.login} GitHub user`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-13">{collaborator.login}</p>
|
||||
</div>
|
||||
<div>
|
||||
<CustomSelect
|
||||
value={users[index].import}
|
||||
label={<div className="text-11">{importOptions.find((o) => o.key === users[index].import)?.label}</div>}
|
||||
onChange={(val: any) => {
|
||||
const newUsers = [...users];
|
||||
newUsers[index].import = val;
|
||||
newUsers[index].email = "";
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
noChevron
|
||||
>
|
||||
{importOptions.map((option) => (
|
||||
<CustomSelect.Option key={option.label} value={option.key}>
|
||||
<div>{option.label}</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
{users[index].import === "invite" && (
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name={`userEmail${index}`}
|
||||
value={users[index].email}
|
||||
onChange={(e) => {
|
||||
const newUsers = [...users];
|
||||
newUsers[index].email = e.target.value;
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
placeholder="Enter email of the user"
|
||||
className="w-full py-1 text-11"
|
||||
/>
|
||||
)}
|
||||
{users[index].import === "map" && members && (
|
||||
<CustomSearchSelect
|
||||
value={users[index].email}
|
||||
label={users[index].email !== "" ? users[index].email : "Select user from project"}
|
||||
options={options}
|
||||
onChange={(val: string) => {
|
||||
const newUsers = [...users];
|
||||
newUsers[index].email = val;
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import useSWR, { mutate } from "swr";
|
||||
// icons
|
||||
import { RefreshCw } from "lucide-react";
|
||||
// plane imports
|
||||
import { IMPORTERS_LIST } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IImporterService } from "@plane/types";
|
||||
// assets
|
||||
import GithubLogo from "@/app/assets/services/github.png?url";
|
||||
import JiraLogo from "@/app/assets/services/jira.svg?url";
|
||||
// components
|
||||
import { DeleteImportModal, GithubImporterRoot, JiraImporterRoot, SingleImport } from "@/components/integration";
|
||||
import { ImportExportSettingsLoader } from "@/components/ui/loader/settings/import-and-export";
|
||||
// constants
|
||||
import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { IntegrationService } from "@/services/integrations";
|
||||
|
||||
// services
|
||||
const integrationService = new IntegrationService();
|
||||
|
||||
const getImporterLogo = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return GithubLogo;
|
||||
case "jira":
|
||||
return JiraLogo;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// FIXME: [Deprecated] Remove this component
|
||||
const IntegrationGuide = observer(function IntegrationGuide() {
|
||||
// states
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [deleteImportModal, setDeleteImportModal] = useState(false);
|
||||
const [importToDelete, setImportToDelete] = useState<IImporterService | null>(null);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const provider = searchParams.get("provider");
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: importerServices } = useSWR(
|
||||
workspaceSlug ? IMPORTER_SERVICES_LIST(workspaceSlug) : null,
|
||||
workspaceSlug ? () => integrationService.getImporterServicesList(workspaceSlug) : null
|
||||
);
|
||||
|
||||
const handleDeleteImport = (importService: IImporterService) => {
|
||||
setImportToDelete(importService);
|
||||
setDeleteImportModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteImportModal
|
||||
isOpen={deleteImportModal}
|
||||
handleClose={() => setDeleteImportModal(false)}
|
||||
data={importToDelete}
|
||||
user={currentUser || null}
|
||||
/>
|
||||
<div className="h-full">
|
||||
{(!provider || provider === "csv") && (
|
||||
<>
|
||||
{/* <div className="mb-5 flex items-center gap-2">
|
||||
<div className="h-full w-full space-y-1">
|
||||
<div className="text-16 font-medium">Relocation Guide</div>
|
||||
<div className="text-13">
|
||||
You can now transfer all the work items that you{"'"}ve created in other tracking
|
||||
services. This tool will guide you to relocate the work item to Plane.
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.plane.so/importers/github"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="flex flex-shrink-0 cursor-pointer items-center gap-2 whitespace-nowrap text-13 font-medium text-[#3F76FF] hover:text-opacity-80">
|
||||
Read More
|
||||
<ArrowRightIcon width={"18px"} color={"#3F76FF"} />
|
||||
</div>
|
||||
</a>
|
||||
</div> */}
|
||||
{IMPORTERS_LIST.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="flex items-center justify-between gap-2 border-b border-subtle bg-surface-1 px-4 py-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<img
|
||||
src={getImporterLogo(service?.provider)}
|
||||
className="h-full w-full object-cover"
|
||||
alt={`${t(service.i18n_title)} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-4 text-13 font-medium">{t(service.i18n_title)}</h3>
|
||||
<p className="text-13 tracking-tight text-secondary">{t(service.i18n_description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}>
|
||||
<span>
|
||||
<Button variant="primary">{service.type}</Button>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-center border-b border-subtle pb-3.5 pt-7">
|
||||
<h3 className="flex gap-2 text-18 font-medium">
|
||||
Previous Imports
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11 outline-none"
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
mutate(IMPORTER_SERVICES_LIST(workspaceSlug)).then(() => setRefreshing(false));
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? "Refreshing..." : "Refresh status"}
|
||||
</button>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{importerServices ? (
|
||||
importerServices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-subtle-1">
|
||||
{importerServices.map((service) => (
|
||||
<SingleImport
|
||||
key={service.id}
|
||||
service={service}
|
||||
refreshing={refreshing}
|
||||
handleDelete={() => handleDeleteImport(service)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* <EmptyState type={EmptyStateType.WORKSPACE_SETTINGS_IMPORT} size="sm" /> */}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ImportExportSettingsLoader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider && provider === "github" && <GithubImporterRoot />}
|
||||
{provider && provider === "jira" && <JiraImporterRoot />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IntegrationGuide;
|
||||
|
||||
export { IntegrationGuide };
|
||||
@@ -1,12 +0,0 @@
|
||||
// layout
|
||||
export * from "./delete-import-modal";
|
||||
export * from "./guide";
|
||||
export * from "./single-import";
|
||||
export * from "./single-integration-card";
|
||||
|
||||
// github
|
||||
export * from "./github";
|
||||
// jira
|
||||
export * from "./jira";
|
||||
// slack
|
||||
export * from "./slack";
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
// react hook form
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { IJiraImporterForm } from "@plane/types";
|
||||
|
||||
// types
|
||||
|
||||
export function JiraConfirmImport() {
|
||||
const { watch } = useFormContext<IJiraImporterForm>();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-2">
|
||||
<h3 className="text-16 font-semibold">Confirm</h3>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<p className="text-13 text-secondary">Migrating</p>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="mb-2 text-16 font-semibold">{watch("data.total_issues")}</h4>
|
||||
<p className="text-13 text-secondary">Work items</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-16 font-semibold">{watch("data.total_states")}</h4>
|
||||
<p className="text-13 text-secondary">States</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-16 font-semibold">{watch("data.total_modules")}</h4>
|
||||
<p className="text-13 text-secondary">Modules</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-16 font-semibold">{watch("data.total_labels")}</h4>
|
||||
<p className="text-13 text-secondary">Labels</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-16 font-semibold">{watch("data.users").filter((user) => user.import).length}</h4>
|
||||
<p className="text-13 text-secondary">User</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
import { PlusIcon } from "@plane/propel/icons";
|
||||
import type { IJiraImporterForm } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "@plane/utils";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// types
|
||||
|
||||
export const JiraGetImportDetail = observer(function JiraGetImportDetail() {
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { workspaceProjectIds, getProjectById } = useProject();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<IJiraImporterForm>();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full space-y-8 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-semibold">Jira Personal Access Token</h3>
|
||||
<p className="text-13 text-secondary">
|
||||
Get to know your access token by navigating to{" "}
|
||||
<Link href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noreferrer">
|
||||
<span className="text-accent-primary underline">Atlassian Settings</span>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="metadata.api_token"
|
||||
rules={{
|
||||
required: "Please enter your personal access token.",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="metadata.api_token"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.metadata?.api_token)}
|
||||
placeholder="XXXXXXXX"
|
||||
className="w-full"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.metadata?.api_token && (
|
||||
<p className="text-11 text-danger-primary">{errors.metadata.api_token.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-semibold">Jira Project Key</h3>
|
||||
<p className="text-13 text-secondary">If XXX-123 is your work item, then enter XXX</p>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="metadata.project_key"
|
||||
rules={{
|
||||
required: "Please enter your project key.",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="metadata.project_key"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.metadata?.project_key)}
|
||||
placeholder="LIN"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.metadata?.project_key && (
|
||||
<p className="text-11 text-danger-primary">{errors.metadata.project_key.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-semibold">Jira Email Address</h3>
|
||||
<p className="text-13 text-secondary">Enter the Email account that you use in Jira account</p>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="metadata.email"
|
||||
rules={{
|
||||
required: "Please enter email address.",
|
||||
validate: (value) => checkEmailValidity(value) || "Please enter a valid email address",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="metadata.email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.metadata?.email)}
|
||||
placeholder="name@company.com"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.metadata?.email && <p className="text-11 text-danger-primary">{errors.metadata.email.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-semibold">Jira Installation or Cloud Host Name</h3>
|
||||
<p className="text-13 text-secondary">Enter your companies cloud host name</p>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="metadata.cloud_hostname"
|
||||
rules={{
|
||||
required: "Please enter your cloud host name.",
|
||||
validate: (value) => !/^https?:\/\//.test(value) || "Hostname should not begin with http:// or https://",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="metadata.cloud_hostname"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.metadata?.cloud_hostname)}
|
||||
placeholder="my-company.atlassian.net"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.metadata?.cloud_hostname && (
|
||||
<p className="text-11 text-danger-primary">{errors.metadata.cloud_hostname.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-semibold">Import to project</h3>
|
||||
<p className="text-13 text-secondary">Select which project you want to import to.</p>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_id"
|
||||
rules={{ required: "Please select a project." }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
input
|
||||
onChange={onChange}
|
||||
label={
|
||||
<span>
|
||||
{value && value.trim() !== "" ? (
|
||||
getProjectById(value)?.name
|
||||
) : (
|
||||
<span className="text-secondary">Select a project</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{workspaceProjectIds && workspaceProjectIds.length > 0 ? (
|
||||
workspaceProjectIds.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
if (!projectDetails) return;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={projectId} value={projectId}>
|
||||
{projectDetails.name}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex cursor-pointer select-none items-center space-x-2 truncate rounded-sm px-1 py-1.5 text-secondary">
|
||||
<p>You don{"'"}t have any project. Please create a project first.</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="flex cursor-pointer select-none items-center space-x-2 truncate rounded-sm px-1 py-1.5 text-secondary"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-secondary" />
|
||||
<span>Create new project</span>
|
||||
</button>
|
||||
</div>
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useFormContext, useFieldArray, Controller } from "react-hook-form";
|
||||
import useSWR from "swr";
|
||||
// plane types
|
||||
import type { IJiraImporterForm } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui";
|
||||
// constants
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export function JiraImportUsers() {
|
||||
const { workspaceSlug } = useParams();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormContext<IJiraImporterForm>();
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: "data.users",
|
||||
});
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug?.toString() ?? "") : null,
|
||||
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug?.toString() ?? "") : null
|
||||
);
|
||||
|
||||
const options = members
|
||||
?.map((member) => {
|
||||
if (!member?.member) return;
|
||||
return {
|
||||
value: member.member.email,
|
||||
query: member.member.display_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={member?.member.display_name} src={getFileURL(member?.member.avatar_url)} />
|
||||
{member.member.display_name}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((member) => !!member) as
|
||||
| {
|
||||
value: string;
|
||||
query: string;
|
||||
content: React.ReactNode;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full space-y-10 divide-y-2 divide-subtle-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-semibold">Users</h3>
|
||||
<p className="text-13 text-secondary">Update, invite or choose not to invite assignee</p>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="data.invite_users"
|
||||
render={({ field: { value, onChange } }) => <ToggleSwitch onChange={onChange} value={value} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{watch("data.invite_users") && (
|
||||
<div className="pt-6">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-1 text-13 text-secondary">Name</div>
|
||||
<div className="col-span-1 text-13 text-secondary">Import as</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{fields.map((user, index) => (
|
||||
<div className="grid grid-cols-3 gap-3" key={`${user.email}-${user.username}`}>
|
||||
<div className="col-span-1">
|
||||
<p>{user.username}</p>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`data.users.${index}.import`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={<span className="capitalize">{value ? value : ("Ignore" as any)}</span>}
|
||||
>
|
||||
<CustomSelect.Option value="invite">Invite by email</CustomSelect.Option>
|
||||
<CustomSelect.Option value="map">Map to existing</CustomSelect.Option>
|
||||
<CustomSelect.Option value={false}>Do not import</CustomSelect.Option>
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
{watch(`data.users.${index}.import`) === "invite" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`data.users.${index}.email`}
|
||||
rules={{
|
||||
required: "This field is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id={`data.users.${index}.email`}
|
||||
name={`data.users.${index}.email`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.data?.users?.[index]?.email)}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{watch(`data.users.${index}.import`) === "map" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`data.users.${index}.email`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
input
|
||||
label={value !== "" ? value : "Select user from project"}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user