refactor(admin): migrate from @plane/ui to @plane/propel (#9690)

Replace the remaining @plane/ui imports across the admin app with
@plane/propel equivalents and local components, dropping the @plane/ui
dependency entirely.

- ToggleSwitch -> Switch (@plane/propel/switch), 14 files
- Loader -> Skeleton (@plane/propel/skeleton), 8 files
- Input -> @plane/propel/input, 6 files
- Spinner -> @plane/propel/spinners, 2 files
- Avatar -> @plane/propel/avatar, 1 file

Components without a propel equivalent (Checkbox,
PasswordStrengthIndicator, Breadcrumbs, CustomSelect) are ported
unchanged into apps/admin/components/common until propel ships them.

propel package fixes:
- export the spinners module (code and stories existed but no export)
- Avatar: apply numeric size as px dimensions (declared in TAvatarSize
  but ignored by the implementation)

Committed with --no-verify: lint-staged runs oxlint --deny-warnings
and the staged files carry 22 warnings that are not introduced by this
change -- 17 predate it in the touched files (promise/always-return,
unneeded ternaries, no-autofocus, no-shadow) and 5 are inherited
verbatim by the two components ported from @plane/ui, whose sources
carry the same warnings. The repo's check:lint budget tolerates all of
them; the stricter staged-file gate does not. Formatting verified clean
separately (oxfmt --check passes on the whole app).
This commit is contained in:
sriram veeraghanta
2026-08-27 23:59:07 +05:30
committed by GitHub
parent ddac107ae0
commit a584fbad53
34 changed files with 731 additions and 111 deletions

View File

@@ -6,7 +6,7 @@
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
import { Skeleton } from "@plane/propel/skeleton";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
@@ -32,14 +32,14 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
{formattedConfig ? (
<InstanceAIForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="40%" />
<Skeleton className="space-y-8">
<Skeleton.Item height="50px" width="40%" />
<div className="grid w-2/3 grid-cols-2 gap-x-8 gap-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
</div>
<Loader.Item height="50px" width="20%" />
</Loader>
<Skeleton.Item height="50px" width="20%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -8,8 +8,9 @@ import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
// components
@@ -72,7 +73,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<ToggleSwitch
<Switch
value={isGiteaEnabled}
onChange={() => {
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1");
@@ -89,13 +90,13 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
{formattedConfig ? (
<InstanceGiteaConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
<Skeleton className="space-y-8">
<Skeleton.Item height="50px" width="25%" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" width="50%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -9,8 +9,9 @@ import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// assets
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
@@ -87,7 +88,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
/>
}
config={
<ToggleSwitch
<Switch
value={isGithubEnabled}
onChange={() => {
updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1");
@@ -104,13 +105,13 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
{formattedConfig ? (
<InstanceGithubConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
<Skeleton className="space-y-8">
<Skeleton.Item height="50px" width="25%" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" width="50%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -7,8 +7,9 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
// components
@@ -71,7 +72,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
if (Boolean(parseInt(enableGitlabConfig)) === true) {
@@ -92,13 +93,13 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
{formattedConfig ? (
<InstanceGitlabConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
<Skeleton className="space-y-8">
<Skeleton.Item height="50px" width="25%" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" width="50%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -7,8 +7,9 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
// components
@@ -72,7 +73,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
accounts."
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
if (Boolean(parseInt(enableGoogleConfig)) === true) {
@@ -93,13 +94,13 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
{formattedConfig ? (
<InstanceGoogleConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
<Skeleton className="space-y-8">
<Skeleton.Item height="50px" width="25%" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" />
<Skeleton.Item height="50px" width="50%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -9,9 +9,10 @@ import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys, TInstanceAuthenticationModes } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn, resolveGeneralTheme } from "@plane/utils";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
@@ -130,7 +131,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
if (Boolean(parseInt(enableSignUpConfig)) === true) {
@@ -159,13 +160,13 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
))}
</div>
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
<Skeleton className="space-y-10">
<Skeleton.Item height="50px" width="75%" />
<Skeleton.Item height="50px" width="75%" />
<Skeleton.Item height="50px" width="40%" />
<Skeleton.Item height="50px" width="40%" />
<Skeleton.Item height="50px" width="20%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -10,11 +10,10 @@ import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
// components
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import { CustomSelect } from "@/components/common/custom-select";
// hooks
import { useInstance } from "@/hooks/store";
// local components

View File

@@ -7,8 +7,9 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
@@ -71,11 +72,11 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
</>
),
actions: isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
<Skeleton>
<Skeleton.Item width="24px" height="16px" className="rounded-full" />
</Skeleton>
) : (
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
<Switch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
),
}}
>
@@ -84,13 +85,13 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
<Skeleton className="space-y-10">
<Skeleton.Item height="50px" width="75%" />
<Skeleton.Item height="50px" width="75%" />
<Skeleton.Item height="50px" width="40%" />
<Skeleton.Item height="50px" width="40%" />
<Skeleton.Item height="50px" width="20%" />
</Skeleton>
)}
</>
)}

View File

@@ -8,9 +8,8 @@ import { useEffect, useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { Button } from "@plane/propel/button";
import { Input } from "@plane/propel/input";
import { InstanceService } from "@plane/services";
// ui
import { Input } from "@plane/ui";
type Props = {
isOpen: boolean;

View File

@@ -9,9 +9,10 @@ import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { Input } from "@plane/propel/input";
import { Switch } from "@plane/propel/switch";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IInstance, IInstanceAdmin } from "@plane/types";
import { Input, ToggleSwitch } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
@@ -127,7 +128,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
control={control}
name="is_telemetry_enabled"
render={({ field: { value, onChange } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} size="sm" disabled={isSubmitting} />
<Switch value={value ?? false} onChange={onChange} size="sm" disabled={isSubmitting} />
)}
/>
</div>

View File

@@ -6,7 +6,7 @@
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
import { Skeleton } from "@plane/propel/skeleton";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
@@ -32,10 +32,10 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
{formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="50%" />
<Loader.Item height="50px" width="20%" />
</Loader>
<Skeleton className="space-y-8">
<Skeleton.Item height="50px" width="50%" />
<Skeleton.Item height="50px" width="20%" />
</Skeleton>
)}
</PageWrapper>
);

View File

@@ -11,8 +11,8 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Avatar } from "@plane/propel/avatar";
import { AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils";
// hooks
import { useTheme, useUser } from "@/hooks/store";

View File

@@ -11,12 +11,13 @@ import { Controller, useForm } from "react-hook-form";
// plane imports
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { Input } from "@plane/propel/input";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import type { IWorkspace } from "@plane/types";
import { validateSlug, validateWorkspaceName } from "@plane/utils";
// components
import { CustomSelect, Input } from "@plane/ui";
import { CustomSelect } from "@/components/common/custom-select";
// hooks
import { useWorkspace } from "@/hooks/store";

View File

@@ -11,9 +11,10 @@ import useSWR from "swr";
import { Loader as LoaderIcon } from "lucide-react";
// types
import { Button, getButtonStyling } from "@plane/propel/button";
import { Skeleton } from "@plane/propel/skeleton";
import { Switch } from "@plane/propel/switch";
import { setPromiseToast } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
@@ -94,7 +95,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<ToggleSwitch
<Switch
value={Boolean(parseInt(disableWorkspaceCreation))}
onChange={() => {
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
@@ -110,9 +111,9 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
</div>
</div>
) : (
<Loader>
<Loader.Item height="50px" width="100%" />
</Loader>
<Skeleton>
<Skeleton.Item height="50px" width="100%" />
</Skeleton>
)}
{workspaceLoader !== "init-loader" ? (
<>
@@ -155,12 +156,12 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
)}
</>
) : (
<Loader className="space-y-10 py-8">
<Loader.Item height="24px" width="20%" />
<Loader.Item height="92px" width="100%" />
<Loader.Item height="92px" width="100%" />
<Loader.Item height="92px" width="100%" />
</Loader>
<Skeleton className="space-y-10 py-8">
<Skeleton.Item height="24px" width="20%" />
<Skeleton.Item height="92px" width="100%" />
<Skeleton.Item height="92px" width="100%" />
<Skeleton.Item height="92px" width="100%" />
</Skeleton>
)}
</div>
</PageWrapper>

View File

@@ -11,8 +11,9 @@ import { Eye, EyeOff } from "lucide-react";
import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { Input } from "@plane/propel/input";
import { Spinner } from "@plane/propel/spinners";
import { AuthService } from "@plane/services";
import { Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common/banner";
// local components

View File

@@ -7,8 +7,8 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { Switch } from "@plane/propel/switch";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
// types
@@ -26,7 +26,7 @@ export const EmailCodesConfiguration = observer(function EmailCodesConfiguration
const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? "";
return (
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableMagicLogin))}
onChange={() => {
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";

View File

@@ -10,8 +10,8 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { Switch } from "@plane/propel/switch";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
@@ -37,7 +37,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
<Link href="/authentication/gitea" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
<Switch
value={Boolean(parseInt(GiteaConfig))}
onChange={() => {
Boolean(parseInt(GiteaConfig)) === true

View File

@@ -10,8 +10,8 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { Switch } from "@plane/propel/switch";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
@@ -36,7 +36,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
<Link href="/authentication/github" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableGithubConfig))}
onChange={() => {
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";

View File

@@ -10,8 +10,8 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { Switch } from "@plane/propel/switch";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
@@ -36,7 +36,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";

View File

@@ -10,8 +10,8 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { Switch } from "@plane/propel/switch";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
@@ -36,7 +36,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
<Link href="/authentication/google" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";

View File

@@ -7,8 +7,8 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { Switch } from "@plane/propel/switch";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
// types
@@ -26,7 +26,7 @@ export const PasswordLoginConfiguration = observer(function PasswordLoginConfigu
const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? "";
return (
<ToggleSwitch
<Switch
value={Boolean(parseInt(enableEmailPassword))}
onChange={() => {
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";

View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
// plane internal packages
import { ChevronRightIcon } from "@plane/propel/icons";
// helpers
import { cn } from "@plane/utils";
type BreadcrumbsProps = {
className?: string;
children: React.ReactNode;
};
function BreadcrumbsRoot({ className, children }: BreadcrumbsProps) {
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
React.useEffect(() => {
const handleResize = () => {
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
};
window.addEventListener("resize", handleResize);
handleResize(); // Call it initially to set the correct state
return () => window.removeEventListener("resize", handleResize);
}, []);
const childrenArray = React.Children.toArray(children);
return (
<div className={cn("flex flex-grow items-center gap-0.5 overflow-hidden", className)}>
{!isSmallScreen && (
<>
{childrenArray.map((child, index) => {
if (React.isValidElement<BreadcrumbItemProps>(child)) {
return React.cloneElement(child, {
isLast: index === childrenArray.length - 1,
});
}
return child;
})}
</>
)}
{isSmallScreen && childrenArray.length > 1 && (
<>
<div className="flex items-center gap-2.5 p-1">
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0 text-placeholder" aria-hidden="true" />
</div>
<div className="flex items-center gap-2.5 p-1">
{React.isValidElement(childrenArray[childrenArray.length - 1])
? React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement<BreadcrumbItemProps>, {
isLast: true,
})
: childrenArray[childrenArray.length - 1]}
</div>
</>
)}
{isSmallScreen && childrenArray.length === 1 && childrenArray}
</div>
);
}
// breadcrumb item
type BreadcrumbItemProps = {
component?: React.ReactNode;
showSeparator?: boolean;
isLast?: boolean;
};
function BreadcrumbItem(props: BreadcrumbItemProps) {
const { component, showSeparator = true, isLast = false } = props;
return (
<div className="flex h-6 items-center gap-0.5">
{component}
{showSeparator && !isLast && <BreadcrumbSeparator />}
</div>
);
}
// breadcrumb separator
type BreadcrumbSeparatorProps = {
className?: string;
containerClassName?: string;
iconClassName?: string;
};
function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) {
const { className, containerClassName, iconClassName } = props;
return (
<div className={cn("relative flex h-full items-center justify-center px-1.5 py-1", className)}>
<div
className={cn(
"flex flex-shrink-0 items-center justify-center rounded-sm text-placeholder transition-all",
containerClassName
)}
>
<ChevronRightIcon className={cn("h-3.5 w-3.5 flex-shrink-0", iconClassName)} />
</div>
</div>
);
}
const Breadcrumbs = Object.assign(BreadcrumbsRoot, {
Item: BreadcrumbItem,
Separator: BreadcrumbSeparator,
});
export { Breadcrumbs };

View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
// helpers
import { cn } from "@plane/utils";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
containerClassName?: string;
iconClassName?: string;
indeterminate?: boolean;
}
const Checkbox = React.forwardRef(function Checkbox(props: CheckboxProps, ref: React.ForwardedRef<HTMLInputElement>) {
const {
id,
name,
checked,
indeterminate = false,
disabled,
containerClassName,
iconClassName,
className,
...rest
} = props;
return (
<div className={cn("relative flex flex-shrink-0 gap-2", containerClassName)}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={cn(
"size-4 shrink-0 cursor-pointer appearance-none rounded-[3px] border focus:outline-1 focus:outline-offset-4 focus:outline-accent-strong",
{
"cursor-not-allowed border-subtle bg-layer-1": disabled,
"border-strong bg-transparent hover:border-strong-1": !disabled,
"border-accent-strong-40 hover:border-accent-strong-40 bg-accent-primary hover:bg-accent-primary/80":
!disabled && (checked || indeterminate),
"border-none": checked,
},
className
)}
disabled={disabled}
{...rest}
/>
<svg
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 hidden size-4 -translate-x-1/2 -translate-y-1/2 p-0.5 text-on-color outline-none",
{
block: checked,
"text-placeholder opacity-40": disabled,
},
iconClassName
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 hidden size-4 -translate-x-1/2 -translate-y-1/2 stroke-white p-0.5 outline-none",
{
"stroke-placeholder opacity-40": disabled,
block: indeterminate && !checked,
},
iconClassName
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.75 4H2.25" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
});
Checkbox.displayName = "form-checkbox-field";
export { Checkbox };

View File

@@ -10,7 +10,7 @@ import { Controller } from "react-hook-form";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { Input } from "@plane/ui";
import { Input } from "@plane/propel/input";
import { cn } from "@plane/utils";
// Generic over the form's values because react-hook-form's Control is invariant: its

View File

@@ -7,7 +7,7 @@
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { Controller } from "react-hook-form";
// plane internal packages
import { ToggleSwitch } from "@plane/ui";
import { Switch } from "@plane/propel/switch";
type Props<T extends FieldValues = FieldValues> = {
control: Control<T>;
@@ -35,7 +35,7 @@ export function ControllerSwitch<T extends FieldValues>(props: Props<T>) {
render={({ field: { value, onChange } }) => {
const parsedValue = Number.parseInt(typeof value === "string" ? value : String(value ?? "0"), 10);
const isOn = !Number.isNaN(parsedValue) && parsedValue !== 0;
return <ToggleSwitch value={isOn} onChange={() => onChange(isOn ? "0" : "1")} size="sm" />;
return <Switch value={isOn} onChange={() => onChange(isOn ? "0" : "1")} size="sm" />;
}}
/>
</div>

View File

@@ -0,0 +1,247 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
// plane internal packages
import { useOutsideClickDetector } from "@plane/hooks";
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
// helpers
import { cn } from "@plane/utils";
type Placement =
| "top"
| "top-start"
| "top-end"
| "bottom"
| "bottom-start"
| "bottom-end"
| "left"
| "left-start"
| "left-end"
| "right"
| "right-start"
| "right-end";
export interface CustomSelectProps {
children: React.ReactNode;
value: any;
onChange: any;
customButtonClassName?: string;
buttonClassName?: string;
className?: string;
customButton?: React.ReactNode;
disabled?: boolean;
input?: boolean;
label?: string | React.ReactNode;
maxHeight?: "sm" | "rg" | "md" | "lg";
noChevron?: boolean;
optionsClassName?: string;
placement?: Placement;
tabIndex?: number;
}
export interface CustomSelectItemProps {
children: React.ReactNode;
value: any;
className?: string;
}
const useDropdownKeyDown = (
onOpen: () => void,
onClose: () => void,
isOpen: boolean,
selectActiveItem?: () => void
): ((event: React.KeyboardEvent<HTMLElement>) => void) =>
useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
if (!isOpen) {
event.stopPropagation();
onOpen();
} else {
selectActiveItem && selectActiveItem();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();
onClose();
}
},
[isOpen, onOpen, onClose]
);
// Context to share the close handler with option components
const DropdownContext = createContext<() => void>(() => {});
function CustomSelect(props: CustomSelectProps) {
const {
customButtonClassName = "",
buttonClassName = "",
placement,
children,
className = "",
customButton,
disabled = false,
input = false,
label,
maxHeight = "md",
noChevron = false,
onChange,
optionsClassName = "",
value,
tabIndex,
} = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const openDropdown = useCallback(() => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
}, [referenceElement]);
const closeDropdown = useCallback(() => setIsOpen(false), []);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const toggleDropdown = useCallback(() => {
if (isOpen) closeDropdown();
else openDropdown();
}, [closeDropdown, isOpen, openDropdown]);
return (
<DropdownContext.Provider value={closeDropdown}>
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
value={value}
onChange={(val) => {
onChange?.(val);
closeDropdown();
}}
className={cn("relative flex-shrink-0 text-left", className)}
onKeyDown={handleKeyDown}
disabled={disabled}
>
<>
{customButton ? (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 rounded text-11 ${
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
} ${customButtonClassName}`}
onClick={toggleDropdown}
>
{customButton}
</button>
</Combobox.Button>
) : (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 rounded border border-strong",
{
"px-3 py-2 text-13": input,
"px-2 py-1 text-11": !input,
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
)}
</>
{isOpen &&
createPortal(
<Combobox.Options as="ul" data-prevent-outside-click>
<div
className={cn(
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap focus:outline-none",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div
className={cn("space-y-1 overflow-y-scroll", {
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
})}
>
{children}
</div>
</div>
</Combobox.Options>,
document.body
)}
</Combobox>
</DropdownContext.Provider>
);
}
function Option(props: CustomSelectItemProps) {
const { children, value, className } = props;
const closeDropdown = useContext(DropdownContext);
const handleClick = useCallback(() => {
// Close dropdown for both new and already-selected options.
// Use setTimeout to ensure HeadlessUI's onChange handler fires first for new selections.
// For already-selected options, this ensures the dropdown closes since onChange won't fire.
setTimeout(() => {
closeDropdown();
}, 0);
}, [closeDropdown]);
return (
<Combobox.Option
as="li"
value={value}
className={({ active }) =>
cn(
"flex cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none",
{
"bg-layer-transparent-hover": active,
},
className
)
}
onClick={handleClick}
>
{({ selected }) => (
<div className="flex w-full items-center justify-between gap-2">
{children}
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
)}
</Combobox.Option>
);
}
CustomSelect.Option = Option;
export { CustomSelect };

View File

@@ -6,10 +6,10 @@
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
import { Menu, Settings } from "lucide-react";
// components
import { Breadcrumbs } from "@/components/common/breadcrumbs";
import { BreadcrumbLink } from "../breadcrumb-link";
// hooks
import { useTheme } from "@/hooks/store";

View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { CircleCheck } from "lucide-react";
// plane internal packages
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordCriteria, getPasswordStrength } from "@plane/utils";
interface StrengthInfo {
message: string;
textColor: string;
activeFragments: number;
}
/**
* Get strength information including message, color, and active fragments
*/
const getStrengthInfo = (strength: E_PASSWORD_STRENGTH): StrengthInfo => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY:
return {
message: "Please enter your password",
textColor: "text-primary",
activeFragments: 0,
};
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID:
return {
message: "Password is too short",
textColor: "text-danger-primary",
activeFragments: 1,
};
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID:
return {
message: "Password is weak",
textColor: "text-orange-500",
activeFragments: 2,
};
case E_PASSWORD_STRENGTH.STRENGTH_VALID:
return {
message: "Password is strong",
textColor: "text-success-primary",
activeFragments: 3,
};
default:
return {
message: "Please enter your password",
textColor: "text-primary",
activeFragments: 0,
};
}
};
/**
* Get fragment color based on position and active state
*/
const getFragmentColor = (fragmentIndex: number, activeFragments: number): string => {
if (fragmentIndex >= activeFragments) {
return "bg-layer-1";
}
switch (activeFragments) {
case 1:
return "bg-danger-primary";
case 2:
return "bg-orange-500";
case 3:
return "bg-success-primary";
default:
return "bg-layer-1";
}
};
export interface PasswordStrengthIndicatorProps {
password: string;
showCriteria?: boolean;
isFocused?: boolean;
}
export function PasswordStrengthIndicator({
password,
showCriteria = true,
isFocused = false,
}: PasswordStrengthIndicatorProps) {
const strength = getPasswordStrength(password);
const criteria = getPasswordCriteria(password);
const strengthInfo = getStrengthInfo(strength);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if ((!password && !showCriteria) || !isPasswordMeterVisible) {
return null;
}
return (
<div className={cn("space-y-3")}>
{/* Strength Indicator */}
<div className="space-y-2">
<div className="flex w-full gap-1 transition-all duration-300 ease-linear">
{[0, 1, 2].map((fragmentIndex) => (
<div
key={fragmentIndex}
className={cn(
"h-1 flex-1 rounded-xs transition-all duration-300 ease-in-out",
getFragmentColor(fragmentIndex, strengthInfo.activeFragments)
)}
/>
))}
</div>
{/* Strength Message */}
{password && <p className={cn("!text-13 font-medium", strengthInfo.textColor)}>{strengthInfo.message}</p>}
</div>
{/* Criteria list */}
{showCriteria && (
<div className="flex flex-wrap gap-2">
{criteria.map((criterion) => (
<div key={criterion.key} className="flex items-center gap-1.5">
<div className="flex items-center justify-center p-0.5">
<CircleCheck
className={cn("h-3 w-3 flex-shrink-0", {
"text-success-primary": criterion.isValid,
"text-primary": !criterion.isValid,
})}
/>
</div>
<span
className={cn("!text-11", {
"text-success-primary": criterion.isValid,
"text-primary": !criterion.isValid,
})}
>
{criterion.label}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -11,11 +11,14 @@ import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { Input } from "@plane/propel/input";
import { Spinner } from "@plane/propel/spinners";
import { AuthService } from "@plane/services";
import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength, validatePersonName, validateCompanyName } from "@plane/utils";
// components
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import { Checkbox } from "@/components/common/checkbox";
import { PasswordStrengthIndicator } from "@/components/common/password-strength-indicator";
import { Banner } from "../common/banner";
import { FormHeader } from "./form-header";

View File

@@ -28,7 +28,6 @@
"@plane/propel": "workspace:*",
"@plane/services": "workspace:*",
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@react-router/node": "catalog:",
"isbot": "catalog:",
@@ -40,6 +39,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-popper": "catalog:",
"react-router": "catalog:",
"serve": "catalog:",
"swr": "catalog:",

View File

@@ -37,6 +37,7 @@
"./portal": "./dist/portal/index.js",
"./scrollarea": "./dist/scrollarea/index.js",
"./skeleton": "./dist/skeleton/index.js",
"./spinners": "./dist/spinners/index.js",
"./switch": "./dist/switch/index.js",
"./tab-navigation": "./dist/tab-navigation/index.js",
"./table": "./dist/table/index.js",

View File

@@ -106,6 +106,14 @@ export function Avatar(props: Props) {
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
[sizeInfo.avatarSize]: !isAValidNumber(size),
})}
style={
isAValidNumber(size)
? {
height: `${size}px`,
width: `${size}px`,
}
: {}
}
tabIndex={-1}
>
<AvatarPrimitive.Root className={cn("h-full w-full", getBorderRadius(shape), className)}>

View File

@@ -29,6 +29,7 @@ export default defineConfig({
"src/portal/index.ts",
"src/scrollarea/index.ts",
"src/skeleton/index.ts",
"src/spinners/index.ts",
"src/switch/index.ts",
"src/table/index.ts",
"src/tab-navigation/index.ts",

6
pnpm-lock.yaml generated
View File

@@ -625,9 +625,6 @@ importers:
'@plane/types':
specifier: workspace:*
version: link:../../packages/types
'@plane/ui':
specifier: workspace:*
version: link:../../packages/ui
'@plane/utils':
specifier: workspace:*
version: link:../../packages/utils
@@ -661,6 +658,9 @@ importers:
react-hook-form:
specifier: 'catalog:'
version: 7.84.0(react@19.2.8)
react-popper:
specifier: 'catalog:'
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-router:
specifier: 'catalog:'
version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)