snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
import { Dialog, Transition } from "@headlessui/react";
import { Button } from "@plane/propel/button";
import { useUser } from "@/hooks/store/user";
import type { Props } from "./confirm-workspace-member-remove";
export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMemberRemove(props: Props) {
const { isOpen, onClose, onSubmit, userDetails } = props;
// states
const [isRemoving, setIsRemoving] = useState(false);
// store hooks
const { data: currentUser } = useUser();
const handleClose = () => {
onClose();
setIsRemoving(false);
};
const handleDeletion = async () => {
setIsRemoving(true);
await onSubmit();
handleClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[40rem]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<WarningTriangleOutline className="h-6 w-6 text-danger-primary" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-primary">
{currentUser?.id === userDetails.id
? "Leave workspace?"
: `Remove ${userDetails?.display_name}?`}
</Dialog.Title>
<div className="mt-2">
{currentUser?.id === userDetails.id ? (
<p className="text-13 text-secondary">
Are you sure you want to leave the workspace? You will no longer have access to this
workspace. This action cannot be undone.
</p>
) : (
<p className="text-13 text-secondary">
Are you sure you want to remove member-{" "}
<span className="font-bold">{userDetails?.display_name}</span>? They will no longer have
access to this workspace. This action cannot be undone.
</p>
)}
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
{currentUser?.id === userDetails.id
? isRemoving
? "Leaving"
: "Leave"
: isRemoving
? "Removing"
: "Remove"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ArrowDownOutline, TopArrowOutline } from "@makeplane/propel/icons";
// plane imports
import { Button } from "@plane/propel/button";
import { cn } from "@plane/utils";
// constants
import type { TPlanePlans } from "@/components/workspace/billing/comparison/plans";
import { ComingSoonBadge, PLANE_PLANS, PLANS_LIST } from "@/components/workspace/billing/comparison/plans";
// local imports
import { PlanFeatureDetail } from "./feature-detail";
type TPlansComparisonBaseProps = {
planeDetails: React.ReactNode;
isSelfManaged: boolean;
isCompareAllFeaturesSectionOpen: boolean;
setIsCompareAllFeaturesSectionOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const shouldRenderPlanDetail = (planKey: TPlanePlans) => {
// Free plan is not required to be shown in the comparison
if (planKey === "free") return false;
// Plane one plan is not longer available
if (planKey === "one") return false;
return true;
};
export const PlansComparisonBase = observer(function PlansComparisonBase(props: TPlansComparisonBaseProps) {
const { planeDetails, isSelfManaged, isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen } = props;
// plan details
const { planDetails, planHighlights, planComparison } = PLANE_PLANS;
const numberOfPlansToRender = Object.keys(planDetails).filter((planKey) =>
shouldRenderPlanDetail(planKey as TPlanePlans)
).length;
const getSubscriptionType = (planKey: TPlanePlans) => planDetails[planKey].id;
return (
<div className="horizontal-scrollbar scrollbar-sm size-full overflow-x-auto">
<div className="max-w-full" style={{ minWidth: `${numberOfPlansToRender * 280}px` }}>
<div className="flex h-full flex-col gap-y-10">
<div
className={cn("sticky top-2 z-10 grid flex-shrink-0 gap-3 bg-layer-1 text-caption-md-medium")}
style={{
gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))`,
}}
>
<div className="col-span-1 space-y-0.5 p-3 text-body-sm-medium" />
{planeDetails}
</div>
{/* Plan Headers */}
<section className="flex-shrink-0">
{/* Plan Highlights */}
<div
className="text-caption-md grid gap-3 rounded-xs py-1 text-secondary even:bg-surface-2"
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
>
<div className="col-span-1 p-3 text-body-sm-medium">Highlights</div>
{Object.entries(planHighlights).map(
([planKey, highlights]) =>
shouldRenderPlanDetail(planKey as TPlanePlans) && (
<div key={planKey} className="col-span-1 p-3">
<ul className="list-disc space-y-1 text-body-xs-regular">
{highlights.map((highlight, index) => (
<li key={index}>{highlight}</li>
))}
</ul>
</div>
)
)}
</div>
</section>
{/* Feature Comparison */}
{isCompareAllFeaturesSectionOpen && (
<>
{planComparison.map((section, sectionIdx) => (
<section key={sectionIdx} className="flex-shrink-0">
<h2 className="mb-2 flex items-start gap-2 pl-2 text-h5-semibold text-secondary">
{section.title} {section.comingSoon && <ComingSoonBadge />}
</h2>
<div className="border-t border-subtle">
{section.features.map((feature, featureIdx) => (
<div
key={featureIdx}
className="text-caption-md grid gap-3 rounded-xs bg-layer-transparent text-secondary even:bg-layer-1"
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
>
<div className="col-span-1 flex items-center p-3 text-body-sm-medium">
<div className="flex w-full items-start justify-between gap-2">
{feature.title} {feature.comingSoon && <ComingSoonBadge />}
</div>
</div>
{PLANS_LIST.map(
(planKey) =>
shouldRenderPlanDetail(planKey) && (
<div
key={planKey}
className="col-span-1 flex items-center justify-center p-3 text-center text-body-xs-regular"
>
<PlanFeatureDetail
subscriptionType={getSubscriptionType(planKey)}
data={
isSelfManaged
? (feature["self-hosted"]?.[planKey] ?? feature.cloud[planKey])
: feature.cloud[planKey]
}
/>
</div>
)
)}
</div>
))}
</div>
</section>
))}
</>
)}
</div>
{/* Toggle Button */}
<div className="my-4 flex items-center justify-center gap-1 pb-2">
<Button
variant="ghost"
onClick={() => {
setIsCompareAllFeaturesSectionOpen(!isCompareAllFeaturesSectionOpen);
}}
appendIcon={isCompareAllFeaturesSectionOpen ? <TopArrowOutline /> : <ArrowDownOutline />}
>
{isCompareAllFeaturesSectionOpen ? "Collapse comparison" : "Compare all features"}
</Button>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { MinusCircle } from "lucide-react";
import { MinusOutline, TickCircleOutline } from "@makeplane/propel/icons";
import type { EProductSubscriptionEnum } from "@plane/types";
// plane imports
// constants
import type { TPlanFeatureData } from "@/components/workspace/billing/comparison/plans";
type TPlanFeatureDetailProps = {
subscriptionType: EProductSubscriptionEnum;
data: TPlanFeatureData;
};
export function PlanFeatureDetail(props: TPlanFeatureDetailProps) {
const { data } = props;
if (data === null || data === undefined) {
return <MinusOutline className="size-4 text-placeholder" />;
}
if (data === true) {
return <TickCircleOutline className="size-4 text-accent-primary" />;
}
if (data === false) {
return <MinusCircle className="size-4 text-placeholder" />;
}
return <>{data}</>;
}

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { observer } from "mobx-react";
import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types";
import { calculateYearlyDiscount, cn } from "@plane/utils";
type TPlanFrequencyToggleProps = {
subscriptionType: EProductSubscriptionEnum;
monthlyPrice: number;
yearlyPrice: number;
selectedFrequency: TBillingFrequency;
setSelectedFrequency: (frequency: TBillingFrequency) => void;
};
export const PlanFrequencyToggle = observer(function PlanFrequencyToggle(props: TPlanFrequencyToggleProps) {
const { monthlyPrice, yearlyPrice, selectedFrequency, setSelectedFrequency } = props;
// derived values
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
return (
<div className="flex w-full cursor-pointer items-center py-1">
<div className="flex w-full space-x-1 rounded-md bg-layer-3 p-0.5">
<button
type="button"
onClick={() => setSelectedFrequency("month")}
className={cn(
"w-full rounded-sm px-1 py-0.5 text-center text-caption-sm-medium leading-5",
selectedFrequency === "month"
? "border border-subtle-1 bg-layer-2 text-primary shadow-raised-100"
: "text-tertiary hover:text-secondary"
)}
>
Monthly
</button>
<button
type="button"
onClick={() => setSelectedFrequency("year")}
className={cn(
"w-full rounded-sm px-1 py-0.5 text-center text-caption-sm-medium leading-5",
selectedFrequency === "year"
? "border border-subtle-1 bg-layer-2 text-primary shadow-raised-100"
: "text-tertiary hover:text-secondary"
)}
>
Yearly
{yearlyDiscount > 0 && (
<span className="ml-1.5 rounded-full bg-accent-primary px-1 py-0.5 text-caption-xs-regular text-on-color">
-{yearlyDiscount}%
</span>
)}
</button>
</div>
</div>
);
});

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./base";
export * from "./plans";

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import {
SUBSCRIPTION_REDIRECTION_URLS,
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
TALK_TO_SALES_URL,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { TBillingFrequency } from "@plane/types";
import { EProductSubscriptionEnum } from "@plane/types";
import { getSubscriptionName } from "@plane/utils";
// components
import { DiscountInfo } from "@/components/license/modal/card/discount-info";
import type { TPlanDetail } from "@/components/workspace/billing/comparison/plans";
// local imports
import { PlanFrequencyToggle } from "./frequency-toggle";
type TPlanDetailProps = {
subscriptionType: EProductSubscriptionEnum;
planDetail: TPlanDetail;
billingFrequency: TBillingFrequency | undefined;
setBillingFrequency: (frequency: TBillingFrequency) => void;
};
export const PlanDetail = observer(function PlanDetail(props: TPlanDetailProps) {
const { subscriptionType, planDetail, billingFrequency, setBillingFrequency } = props;
// plane hooks
const { t } = useTranslation();
// subscription details
const subscriptionName = getSubscriptionName(subscriptionType);
const isSubscriptionActive = planDetail.isActive;
// pricing details
const displayPrice = billingFrequency === "month" ? planDetail.monthlyPrice : planDetail.yearlyPrice;
const pricingDescription = isSubscriptionActive ? "a user per month" : "Quote on request";
const pricingSecondaryDescription =
billingFrequency === "month"
? planDetail.monthlyPriceSecondaryDescription
: planDetail.yearlyPriceSecondaryDescription;
const handleRedirection = () => {
const frequency = billingFrequency ?? "year";
// Get the redirection URL based on the subscription type and billing frequency
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL;
// Open the URL in a new tab
window.open(redirectUrl, "_blank");
};
return (
<div className="col-span-1 flex flex-col justify-between space-y-0.5 p-3">
{/* Plan name and pricing section */}
<div className="flex flex-col items-start">
<div className="flex w-full items-center gap-2 text-h4-semibold">
<span>{subscriptionName}</span>
{subscriptionType === EProductSubscriptionEnum.PRO && (
<span className="rounded-sm bg-accent-primary px-2 py-0.5 text-caption-sm-medium text-on-color">
Popular
</span>
)}
</div>
<div className="flex items-start gap-x-2 pb-1 text-tertiary">
{isSubscriptionActive && displayPrice !== undefined && (
<div className="flex items-center gap-1 text-h3-semibold text-primary">
<DiscountInfo
currency="$"
frequency={billingFrequency ?? "month"}
price={displayPrice}
subscriptionType={subscriptionType}
className="mr-1.5"
/>
</div>
)}
<div className="pt-1">
{pricingDescription && <div>{pricingDescription}</div>}
{pricingSecondaryDescription && (
<div className="text-caption-xs text-placeholder">{pricingSecondaryDescription}</div>
)}
</div>
</div>
</div>
{/* Billing frequency toggle */}
{SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) && billingFrequency && (
<div className="h-8 py-0.5">
<PlanFrequencyToggle
subscriptionType={subscriptionType}
monthlyPrice={planDetail.monthlyPrice || 0}
yearlyPrice={planDetail.yearlyPrice || 0}
selectedFrequency={billingFrequency}
setSelectedFrequency={setBillingFrequency}
/>
</div>
)}
{/* Subscription button */}
<div className="flex flex-col items-start gap-1 py-3">
<Button variant="primary" size="lg" onClick={handleRedirection} className="w-full">
{isSubscriptionActive ? `Upgrade to ${subscriptionName}` : t("common.upgrade_cta.talk_to_sales")}
</Button>
</div>
</div>
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types";
// components
import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base";
import type { TPlanePlans } from "@/components/workspace/billing/comparison/plans";
import { PLANE_PLANS } from "@/components/workspace/billing/comparison/plans";
// plane web imports
import { PlanDetail } from "./plan-detail";
type TPlansComparisonProps = {
isCompareAllFeaturesSectionOpen: boolean;
getBillingFrequency: (subscriptionType: EProductSubscriptionEnum) => TBillingFrequency | undefined;
setBillingFrequency: (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency) => void;
setIsCompareAllFeaturesSectionOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const PlansComparison = observer(function PlansComparison(props: TPlansComparisonProps) {
const {
isCompareAllFeaturesSectionOpen,
getBillingFrequency,
setBillingFrequency,
setIsCompareAllFeaturesSectionOpen,
} = props;
// plan details
const { planDetails } = PLANE_PLANS;
return (
<PlansComparisonBase
planeDetails={Object.entries(planDetails).map(([planKey, plan]) => {
const currentPlanKey = planKey as TPlanePlans;
if (!shouldRenderPlanDetail(currentPlanKey)) return null;
return (
<PlanDetail
key={planKey}
subscriptionType={plan.id}
planDetail={plan}
billingFrequency={getBillingFrequency(plan.id)}
setBillingFrequency={(frequency) => setBillingFrequency(plan.id, frequency)}
/>
);
})}
isSelfManaged
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
/>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { DEFAULT_PRODUCT_BILLING_FREQUENCY, SUBSCRIPTION_WITH_BILLING_FREQUENCY } from "@plane/constants";
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";
export const BillingRoot = observer(function BillingRoot() {
const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false);
const [productBillingFrequency, setProductBillingFrequency] = useState<TProductBillingFrequency>(
DEFAULT_PRODUCT_BILLING_FREQUENCY
);
const { t } = useTranslation();
/**
* Retrieves the billing frequency for a given subscription type
* @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to get frequency for
* @returns {TBillingFrequency | undefined} - Billing frequency if subscription supports it, undefined otherwise
*/
const getBillingFrequency = (subscriptionType: EProductSubscriptionEnum): TBillingFrequency | undefined =>
SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType)
? productBillingFrequency[subscriptionType]
: undefined;
/**
* Updates the billing frequency for a specific subscription type
* @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to update
* @param {TBillingFrequency} frequency - New billing frequency to set
* @returns {void}
*/
const setBillingFrequency = (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency): void =>
setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency });
return (
<section className="relative scrollbar-hide size-full overflow-y-auto">
<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>
<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>
);
});

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUser } from "@/hooks/store/user";
export type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
userDetails: {
id: string;
display_name: string;
};
};
export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMemberRemove(props: Props) {
const { isOpen, onClose, onSubmit, userDetails } = props;
// states
const [isRemoving, setIsRemoving] = useState(false);
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
const handleClose = () => {
onClose();
setIsRemoving(false);
};
const handleDeletion = async () => {
setIsRemoving(true);
await onSubmit();
handleClose();
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<WarningTriangleOutline className="h-6 w-6 text-danger-primary" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-h5-medium leading-6 text-primary">
{currentUser?.id === userDetails.id ? "Leave workspace?" : `Remove ${userDetails?.display_name}?`}
</h3>
<div className="mt-2">
{currentUser?.id === userDetails.id ? (
<p className="text-body-xs-regular text-secondary">
{t("workspace_settings.settings.members.leave_confirmation")}
</p>
) : (
<p className="text-body-xs-regular text-secondary">
{/* TODO: Add translation here */}
Are you sure you want to remove member- <span className="font-bold">{userDetails?.display_name}</span>
? They will no longer have access to this workspace. This action cannot be undone.
</p>
)}
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
{currentUser?.id === userDetails.id
? isRemoving
? t("leaving")
: t("leave")
: isRemoving
? t("removing")
: t("remove")}
</Button>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { cn } from "@plane/utils";
import { AppRailRoot } from "@/components/navigation";
import { useAppRailVisibility } from "@/lib/app-rail";
import { TopNavigationRoot } from "@/components/navigation/top-navigation-root";
export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper({
children,
}: {
children: React.ReactNode;
}) {
// Use the context to determine if app rail should render
const { shouldRenderAppRail } = useAppRailVisibility();
return (
<div className="relative flex size-full flex-col overflow-hidden bg-canvas transition-all duration-300 ease-in-out">
<TopNavigationRoot />
<div className="relative flex size-full overflow-hidden">
{/* Conditionally render AppRailRoot based on context */}
{shouldRenderAppRail && <AppRailRoot />}
<div
className={cn(
"relative size-full flex-grow overflow-hidden pr-2 pb-2 pl-2 transition-all duration-300 ease-in-out",
{
"pl-0!": shouldRenderAppRail,
}
)}
>
{children}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,261 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspace } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
import { validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
// services
import { WorkspaceService } from "@/services/workspace.service";
type Props = {
onSubmit?: (res: IWorkspace) => Promise<void>;
defaultValues: {
name: string;
slug: string;
organization_size: string;
};
setDefaultValues: Dispatch<SetStateAction<Pick<IWorkspace, "name" | "slug" | "organization_size">>>;
secondaryButton?: React.ReactNode;
primaryButtonText?: {
loading: string;
default: string;
};
};
const workspaceService = new WorkspaceService();
export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: Props) {
const { t } = useTranslation();
const {
onSubmit,
defaultValues,
setDefaultValues,
secondaryButton,
primaryButtonText = {
loading: "workspace_creation.button.loading",
default: "workspace_creation.button.default",
},
} = props;
// states
const [slugError, setSlugError] = useState(false);
const [invalidSlug, setInvalidSlug] = useState(false);
// router
const router = useAppRouter();
// store hooks
const { createWorkspace } = useWorkspace();
// form info
const {
handleSubmit,
control,
setValue,
getValues,
formState: { errors, isSubmitting, isValid },
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
const handleCreateWorkspace = async (formData: IWorkspace) => {
try {
const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean };
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
try {
const workspaceResponse = await createWorkspace(formData);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
if (onSubmit) await onSubmit(workspaceResponse);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
} else {
setSlugError(true);
}
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
};
useEffect(
() => () => {
// when the component unmounts set the default values to whatever user typed in
setDefaultValues(getValues());
},
[getValues, setDefaultValues]
);
return (
<form
className="space-y-6 sm:space-y-9"
onSubmit={(e) => {
void handleSubmit(handleCreateWorkspace)(e);
}}
>
<div className="space-y-6 sm:space-y-7">
<div className="flex flex-col gap-2 text-13">
<label htmlFor="workspaceName">
{t("workspace_creation.form.name.label")}
<span className="ml-0.5 text-danger-primary">*</span>
</label>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="name"
rules={{
required: t("common.errors.required"),
validate: (value) => validateWorkspaceName(value, true),
maxLength: {
value: 80,
message: t("workspace_creation.errors.validation.name_length"),
},
}}
render={({ field: { value, ref, onChange } }) => (
<Field name="workspaceName" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="workspaceName"
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
setValue("name", e.target.value);
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
shouldValidate: true,
});
}}
ref={ref}
placeholder={t("workspace_creation.form.name.placeholder")}
/>
</InputGroup>
</Field>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
</div>
<div className="flex flex-col gap-2 text-13">
<label htmlFor="workspaceUrl">
{t("workspace_creation.form.url.label")}
<span className="ml-0.5 text-danger-primary">*</span>
</label>
<Controller
control={control}
name="slug"
rules={{
required: t("common.errors.required"),
maxLength: {
value: 48,
message: t("workspace_creation.errors.validation.url_length"),
},
}}
render={({ field: { onChange, value, ref } }) => (
<Field name="workspaceUrl" invalid={invalidSlug || Boolean(errors.slug)}>
<InputGroup size="2xl">
<span className="text-12 whitespace-nowrap text-secondary">{window && window.location.host}/</span>
<Input
size="2xl"
id="workspaceUrl"
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
const validation = validateSlug(e.target.value);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
}}
ref={ref}
placeholder={t("workspace_creation.form.url.placeholder")}
/>
</InputGroup>
</Field>
)}
/>
{slugError && (
<p className="-mt-3 text-13 text-danger-primary">
{t("workspace_creation.errors.validation.url_already_taken")}
</p>
)}
{invalidSlug && (
<p className="text-13 text-danger-primary">{t("workspace_creation.errors.validation.url_alphanumeric")}</p>
)}
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
</div>
<div className="flex flex-col gap-2 text-13">
<span>
{t("workspace_creation.form.organization_size.label")}
<span className="ml-0.5 text-danger-primary">*</span>
</span>
<div className="w-full">
<Controller
name="organization_size"
control={control}
rules={{ required: t("common.errors.required") }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-placeholder">
{t("workspace_creation.form.organization_size.placeholder")}
</span>
)
}
buttonClassName="border border-subtle bg-layer-2 !shadow-none !rounded-md"
input
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.organization_size && (
<span className="text-13 text-danger-primary">{errors.organization_size.message}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
{secondaryButton}
<Button variant="primary" type="submit" size="xl" disabled={!isValid} loading={isSubmitting}>
{isSubmitting ? t(primaryButtonText.loading) : t(primaryButtonText.default)}
</Button>
{!secondaryButton && (
<Button variant="secondary" type="button" size="xl" onClick={() => router.back()}>
{t("common.go_back")}
</Button>
)}
</div>
</form>
);
});

View File

@@ -0,0 +1,171 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
// Plane Imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspace } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserSettings } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
data: IWorkspace | null;
onClose: () => void;
};
const defaultValues = {
workspaceName: "",
confirmDelete: "",
};
export const DeleteWorkspaceForm = observer(function DeleteWorkspaceForm(props: Props) {
const { data, onClose } = props;
// router
const router = useAppRouter();
// store hooks
const { deleteWorkspace } = useWorkspace();
const { t } = useTranslation();
const { getWorkspaceRedirectionUrl } = useWorkspace();
const { fetchCurrentUserSettings } = useUserSettings();
// form info
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({ defaultValues });
const canDelete = watch("workspaceName") === data?.name && watch("confirmDelete") === "delete my workspace";
const handleClose = () => {
const timer = setTimeout(() => {
reset(defaultValues);
clearTimeout(timer);
}, 350);
onClose();
};
const onSubmit = async () => {
if (!data || !canDelete) return;
try {
await deleteWorkspace(data.slug);
await fetchCurrentUserSettings();
handleClose();
router.push(getWorkspaceRedirectionUrl());
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_settings.settings.general.delete_modal.success_title"),
message: t("workspace_settings.settings.general.delete_modal.success_message"),
});
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_settings.settings.general.delete_modal.error_title"),
message: t("workspace_settings.settings.general.delete_modal.error_message"),
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<span
className={cn(
"grid size-12 shrink-0 place-items-center rounded-full bg-danger-subtle text-danger-primary sm:size-10"
)}
>
<WarningTriangleOutline className="size-5 text-danger-primary" aria-hidden="true" />
</span>
<div>
<div className="text-center sm:text-left">
<h3 className="text-h5-medium">{t("workspace_settings.settings.general.delete_modal.title")}</h3>
<p className="mt-1 text-body-xs-regular text-secondary">
You are about to delete the workspace{" "}
<span className="text-body-xs-semibold break-words">{data?.name}</span>. If you confirm, you will lose
access to all your work data in this workspace without any way to restore it. Tread very carefully.
</p>
</div>
<div className="mt-4 text-secondary">
<p className="text-body-xs-regular break-words">Type in this workspace&apos;s name to continue.</p>
<Controller
control={control}
name="workspaceName"
render={({ field: { value, onChange, ref } }) => (
<Field name="workspaceName" invalid={Boolean(errors.workspaceName)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="workspaceName"
name="workspaceName"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder={data?.name}
autoComplete="off"
/>
</InputGroup>
</Field>
)}
/>
</div>
<div className="mt-4 text-secondary">
<p className="text-body-xs-regular">
For final confirmation, type{" "}
<span className="text-body-xs-medium text-primary">delete my workspace </span>
below.
</p>
<Controller
control={control}
name="confirmDelete"
render={({ field: { value, onChange, ref } }) => (
<Field name="confirmDelete" invalid={Boolean(errors.confirmDelete)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="confirmDelete"
name="confirmDelete"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder=""
autoComplete="off"
/>
</InputGroup>
</Field>
)}
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="error-fill" size="lg" type="submit" disabled={!canDelete} loading={isSubmitting}>
{isSubmitting ? t("deleting") : t("confirm")}
</Button>
</div>
</form>
);
});

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import type { IWorkspace } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// constants
// hooks
import { DeleteWorkspaceForm } from "@/components/workspace/delete-workspace-form";
type Props = {
isOpen: boolean;
data: IWorkspace | null;
onClose: () => void;
};
export const DeleteWorkspaceModal = observer(function DeleteWorkspaceModal(props: Props) {
const { isOpen, data, onClose } = props;
return (
<ModalCore isOpen={isOpen} handleClose={() => onClose()} position={EModalPosition.CENTER} width={EModalWidth.XL}>
<DeleteWorkspaceForm data={data} onClose={onClose} />
</ModalCore>
);
});

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IWorkspace } from "@plane/types";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
// local imports
import { DeleteWorkspaceModal } from "./delete-workspace-modal";
type TDeleteWorkspace = {
workspace: IWorkspace | null;
};
export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(props: TDeleteWorkspace) {
const { workspace } = props;
// states
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false);
// translation
const { t } = useTranslation();
return (
<>
<DeleteWorkspaceModal
data={workspace}
isOpen={deleteWorkspaceModal}
onClose={() => setDeleteWorkspaceModal(false)}
/>
<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)}>
{t("delete")}
</Button>
}
/>
</>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@makeplane/propel/components/tooltip";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
import packageJson from "package.json";
// local components
import { Button } from "@plane/propel/button";
import { PaidPlanUpgradeModal } from "@/components/license/modal/upgrade-modal";
export const WorkspaceEditionBadge = observer(function WorkspaceEditionBadge() {
// states
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
// translation
const { t } = useTranslation();
// platform
const { isMobile } = usePlatformOS();
return (
<>
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
/>
<Tooltip label={`Version: v${packageJson.version}`} disabled={isMobile}>
<Button
variant="tertiary"
size="lg"
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
aria-haspopup="dialog"
aria-label={t("aria_labels.projects_sidebar.edition_badge")}
>
Community
</Button>
</Tooltip>
</>
);
});

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { AddOutline } from "@makeplane/propel/icons";
import { cn } from "@plane/utils";
type TInvitationModalActionsProps = {
isInviteDisabled?: boolean;
isSubmitting?: boolean;
handleClose: () => void;
appendField: () => void;
addMoreButtonText?: string;
submitButtonText?: {
default: string;
loading: string;
};
cancelButtonText?: string;
className?: string;
};
export const InvitationModalActions = observer(function InvitationModalActions(props: TInvitationModalActionsProps) {
const {
isInviteDisabled = false,
isSubmitting = false,
handleClose,
appendField,
addMoreButtonText,
submitButtonText,
cancelButtonText,
className,
} = props;
// store hooks
const { t } = useTranslation();
return (
<div className={cn("mt-5 flex items-center justify-between gap-2", className)}>
<button
type="button"
className={cn(
"flex items-center gap-1 bg-transparent py-2 pr-3 text-caption-md-medium text-accent-primary outline-accent-strong",
{
"cursor-not-allowed opacity-60": isInviteDisabled,
}
)}
onClick={appendField}
disabled={isInviteDisabled}
>
<AddOutline className="h-3.5 w-3.5" />
{addMoreButtonText || t("common.add_more")}
</button>
<div className="flex items-center gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
{cancelButtonText || t("cancel")}
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting} disabled={isInviteDisabled}>
{isSubmitting
? submitButtonText?.loading || t("workspace_settings.settings.members.modal.button_loading")
: submitButtonText?.default || t("workspace_settings.settings.members.modal.button")}
</Button>
</div>
</div>
);
});

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import type { Control, FieldArrayWithId, FormState } from "react-hook-form";
import { Controller } from "react-hook-form";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { ROLE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseOutline } from "@makeplane/propel/icons";
import { CustomSelect } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import type { InvitationFormValues } from "@/hooks/use-workspace-invitation";
type TInvitationFieldsProps = {
workspaceSlug: string;
fields: FieldArrayWithId<InvitationFormValues, "emails", "id">[];
control: Control<InvitationFormValues>;
formState: FormState<InvitationFormValues>;
remove: (index: number) => void;
className?: string;
};
export const InvitationFields = observer(function InvitationFields(props: TInvitationFieldsProps) {
const {
workspaceSlug,
fields,
control,
formState: { errors },
remove,
className,
} = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { workspaceInfoBySlug } = useUserPermissions();
// derived values
const currentWorkspaceRole = workspaceInfoBySlug(workspaceSlug.toString())?.role;
return (
<div className={cn("mb-3 space-y-4", className)}>
{fields.map((field, index) => (
<div
key={field.id}
className="group relative mb-1 flex w-full items-start justify-between gap-x-4 text-body-xs-regular"
>
<div className="w-full">
<Controller
control={control}
name={`emails.${index}.email`}
rules={{
required: t("workspace_settings.settings.members.modal.errors.required"),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t("workspace_settings.settings.members.modal.errors.invalid"),
},
}}
render={({ field: { value, onChange, ref } }) => (
<>
<Field name="input" invalid={Boolean(errors.emails?.[index]?.email)}>
<InputGroup size="2xl">
<Input
size="2xl"
id={`emails.${index}.email`}
name={`emails.${index}.email`}
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder={t("workspace_settings.settings.members.modal.placeholder")}
/>
</InputGroup>
</Field>
{errors.emails?.[index]?.email && (
<span className="ml-1 text-caption-sm-regular text-danger-primary">
{errors.emails?.[index]?.email?.message}
</span>
)}
</>
)}
/>
</div>
<div className="flex shrink-0 items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<Controller
control={control}
name={`emails.${index}.role`}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={<span className="text-caption-sm-regular sm:text-body-xs-regular">{ROLE[value]}</span>}
onChange={onChange}
className="w-24 flex-grow"
input
>
{Object.entries(ROLE).map(([key, value]) => {
if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key))
return (
<CustomSelect.Option key={key} value={parseInt(key)}>
{value}
</CustomSelect.Option>
);
})}
</CustomSelect>
)}
/>
</div>
{fields.length > 1 && (
<div className="flex-item flex w-6">
<button
type="button"
className="place-items-center self-center rounded-sm"
onClick={() => remove(index)}
>
<CloseOutline className="h-4 w-4 text-secondary" />
</button>
</div>
)}
</div>
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Dialog } from "@headlessui/react";
type TInvitationFormProps = {
title: string;
description: React.ReactNode;
children: React.ReactNode;
onSubmit: () => void;
actions: React.ReactNode;
className?: string;
};
export const InvitationForm = observer(function InvitationForm(props: TInvitationFormProps) {
const { title, description, children, actions, onSubmit, className } = props;
return (
<form onSubmit={onSubmit} className={className}>
<div className="space-y-4">
<Dialog.Title as="h3" className="text-body-md-medium leading-6 text-primary">
{title}
</Dialog.Title>
<div className="text-body-xs-regular text-secondary">{description}</div>
{children}
</div>
{actions}
</form>
);
});

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { cn, getFileURL } from "@plane/utils";
type Props = {
logo: string | null | undefined;
name: string | undefined;
classNames?: string;
};
export const WorkspaceLogo = observer(function WorkspaceLogo(props: Props) {
// translation
const { t } = useTranslation();
return (
<div
className={cn(
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
!props.logo && "rounded-md bg-accent-primary text-on-color"
} ${props.classNames ? props.classNames : ""}`
)}
>
{props.logo && props.logo !== "" ? (
<img
src={getFileURL(props.logo)}
className="absolute top-0 left-0 h-full w-full rounded-md object-cover"
alt={t("aria_labels.projects_sidebar.workspace_logo")}
/>
) : (
(props.name?.[0] ?? "...")
)}
</div>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./invite-modal";

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { IWorkspaceBulkInviteFormData } from "@plane/types";
import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui";
// components
import { InvitationModalActions } from "@/components/workspace/invite-modal/actions";
import { InvitationFields } from "@/components/workspace/invite-modal/fields";
import { InvitationForm } from "@/components/workspace/invite-modal/form";
// hooks
import { useWorkspaceInvitationActions } from "@/hooks/use-workspace-invitation";
export type TSendWorkspaceInvitationModalProps = {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise<void> | undefined;
};
export const SendWorkspaceInvitationModal = observer(function SendWorkspaceInvitationModal(
props: TSendWorkspaceInvitationModalProps
) {
const { isOpen, onClose, onSubmit } = props;
// store hooks
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
// derived values
const { control, fields, formState, remove, onFormSubmit, handleClose, appendField } = useWorkspaceInvitationActions({
onSubmit,
onClose,
});
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<InvitationForm
title={t("workspace_settings.settings.members.modal.title")}
description={t("workspace_settings.settings.members.modal.description")}
onSubmit={onFormSubmit}
actions={
<InvitationModalActions
isSubmitting={formState.isSubmitting}
handleClose={handleClose}
appendField={appendField}
/>
}
className="p-5"
>
<InvitationFields
workspaceSlug={workspaceSlug.toString()}
fields={fields}
control={control}
formState={formState}
remove={remove}
/>
</InvitationForm>
</ModalCore>
);
});

View File

@@ -0,0 +1,228 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ChevronDownOutline, DeleteOutline, LinkOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { CustomSelect, CustomMenu } from "@plane/ui";
import { cn, copyTextToClipboard } from "@plane/utils";
// components
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
type Props = {
invitationId: string;
};
export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitationsListItem(props: Props) {
const { invitationId } = props;
// router
const { workspaceSlug } = useParams();
// states
const [removeMemberModal, setRemoveMemberModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions, workspaceInfoBySlug } = useUserPermissions();
const {
workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails },
} = useMember();
// derived values
const invitationDetails = getWorkspaceInvitationDetails(invitationId);
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
const currentWorkspaceRole = currentWorkspaceMemberInfo?.role;
// is the current logged in user admin
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
// role change access-
// 1. user cannot change their own role
// 2. only admin or member can change role
// 3. user cannot change role of higher role
const hasRoleChangeAccess = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const handleRemoveInvitation = async () => {
try {
if (!workspaceSlug || !invitationDetails) return;
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Invitation removed successfully.",
});
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error || "Something went wrong. Please try again.",
});
}
};
if (!invitationDetails || !currentWorkspaceMemberInfo) return null;
const handleCopyText = async () => {
try {
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
await copyTextToClipboard(inviteLink);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
});
} catch (error) {
console.error("Error generating invite link:", error);
}
};
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "copy-link",
action: () => void handleCopyText(),
title: t("common.actions.copy_link"),
icon: LinkOutline,
shouldRender: !!invitationDetails.invite_link,
},
{
key: "remove",
action: () => {
setRemoveMemberModal(true);
},
title: t("common.remove"),
icon: DeleteOutline,
shouldRender: isAdmin,
className: "text-danger-primary",
iconClassName: "text-danger-primary",
},
];
return (
<>
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal}
onClose={() => setRemoveMemberModal(false)}
userDetails={{
id: invitationDetails.id,
display_name: `${invitationDetails.email}`,
}}
onSubmit={handleRemoveInvitation}
/>
<div className="group flex h-full w-full items-center justify-between px-3 py-4 hover:bg-layer-transparent-hover">
<div className="flex items-center gap-x-4 gap-y-2">
<span className="relative flex h-10 w-10 items-center justify-center rounded-sm bg-layer-3 p-4 text-tertiary capitalize">
{(invitationDetails.email ?? "?")[0]}
</span>
<div>
<h4 className="cursor-default text-body-xs-regular">{invitationDetails.email}</h4>
</div>
</div>
<div className="flex items-center gap-2 text-11">
<div className="flex items-center justify-center rounded-sm bg-label-yellow-bg-strong/20 px-2.5 py-1 text-center text-caption-sm-medium text-label-yellow-text">
<p>{t("common.pending")}</p>
</div>
<CustomSelect
customButton={
<div className="item-center flex gap-1 rounded-sm px-2 py-0.5">
<span
className={`flex items-center rounded-sm text-caption-sm-medium ${
hasRoleChangeAccess ? "" : "text-placeholder"
}`}
>
{ROLE[invitationDetails.role]}
</span>
{hasRoleChangeAccess && (
<span className="grid place-items-center">
<ChevronDownOutline className="h-3 w-3" />
</span>
)}
</div>
}
value={invitationDetails.role}
onChange={(value: EUserPermissions) => {
if (!workspaceSlug || !value) return;
updateMemberInvitation(workspaceSlug.toString(), invitationDetails.id, {
role: value,
}).catch((err: unknown) => {
const error = err as { error?: string };
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error || "An error occurred while updating member role. Please try again.",
});
});
}}
disabled={!hasRoleChangeAccess}
placement="bottom-end"
>
{Object.keys(ROLE).map((key) => {
if (
currentWorkspaceRole &&
Number(currentWorkspaceRole) !== 20 &&
Number(currentWorkspaceRole) < parseInt(key)
)
return null;
return (
<CustomSelect.Option key={key} value={parseInt(key, 10)}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
{isAdmin && (
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
)}
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,192 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { DeactivatedUserOutline, DeleteOutline } from "@makeplane/propel/icons";
import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspaceMember } from "@plane/types";
// plane ui
import { CustomSelect, PopoverMenu } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
export interface RowData {
member: IWorkspaceMember;
role: EUserPermissions;
is_active: boolean;
}
type NameProps = {
rowData: RowData;
workspaceSlug: string;
isAdmin: boolean;
currentUser: IUser | undefined;
setRemoveMemberModal: (rowData: RowData) => void;
};
type AccountTypeProps = {
rowData: RowData;
workspaceSlug: string;
};
export function NameColumn(props: NameProps) {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
// derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
const isSuspended = rowData.is_active === false;
return (
<Disclosure>
{() => (
<div className="group relative">
<div className="flex w-72 items-center justify-between gap-x-4 gap-y-2">
<div className="flex flex-1 items-center gap-x-2 gap-y-2">
{isSuspended ? (
<div className="rounded-full bg-layer-1">
<DeactivatedUserOutline className="size-6 text-placeholder" />
</div>
) : avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-tertiary capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
<span className={isSuspended ? "text-placeholder" : ""}>
{first_name} {last_name}
</span>
</div>
{!isSuspended && (isAdmin || id === currentUser?.id) && (
<PopoverMenu
data={[""]}
keyExtractor={(item) => item}
popoverClassName="justify-end"
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
render={() => (
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-x-3"
onClick={() => setRemoveMemberModal(rowData)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setRemoveMemberModal(rowData);
}
}}
>
<DeleteOutline className="size-3.5 align-middle" /> {id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
)}
</div>
</div>
)}
</Disclosure>
);
}
export const AccountTypeColumn = observer(function AccountTypeColumn(props: AccountTypeProps) {
const { rowData, workspaceSlug } = props;
// form info
const {
control,
formState: { errors },
} = useForm();
// store hooks
const { allowPermissions } = useUserPermissions();
const {
workspace: { updateMember },
} = useMember();
const { data: currentUser } = useUser();
// derived values
const isCurrentUser = currentUser?.id === rowData.member.id;
const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isRoleNonEditable = isCurrentUser || !isAdminRole;
const isSuspended = rowData.is_active === false;
return (
<>
{isSuspended ? (
<div className="flex w-32">
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
Suspended
</Pill>
</div>
) : isRoleNonEditable ? (
<div className="flex w-32">
<span>{ROLE[rowData.role]}</span>
</div>
) : (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
<CustomSelect
value={value as EUserPermissions}
onChange={async (value: EUserPermissions) => {
if (!workspaceSlug) return;
try {
await updateMember(workspaceSlug.toString(), rowData.member.id, {
role: value as unknown as EUserPermissions,
});
} catch (err: unknown) {
const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
}
}}
label={
<div className="flex">
<span>{ROLE[rowData.role]}</span>
</div>
}
buttonClassName={`!px-0 !justify-start hover:bg-surface-1 ${errors.role ? "border-danger-strong" : "border-none"}`}
className="w-32 rounded-md p-0"
input
>
{Object.keys(ROLE).map((item) => (
<CustomSelect.Option key={item} value={item as unknown as EUserPermissions}>
{ROLE[item as unknown as keyof typeof ROLE]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
)}
</>
);
});

View File

@@ -0,0 +1,120 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceMember } from "@plane/types";
import { Table } from "@plane/ui";
// components
import { MembersLayoutLoader } from "@/components/ui/loader/layouts/members-layout-loader";
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
import type { RowData } from "@/components/workspace/settings/member-columns";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserPermissions, useUserSettings } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { useMemberColumns } from "@/components/workspace/settings/useMemberColumns";
type Props = {
memberDetails: (IWorkspaceMember | null)[];
};
export const WorkspaceMembersListItem = observer(function WorkspaceMembersListItem(props: Props) {
const { memberDetails } = props;
const { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal } = useMemberColumns();
// router
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
workspace: { removeMemberFromWorkspace },
} = useMember();
const { leaveWorkspace } = useUserPermissions();
const { getWorkspaceRedirectionUrl } = useWorkspace();
const { fetchCurrentUserSettings } = useUserSettings();
const { t } = useTranslation();
// derived values
const handleLeaveWorkspace = async () => {
if (!workspaceSlug || !currentUser) return;
try {
await leaveWorkspace(workspaceSlug.toString());
await fetchCurrentUserSettings();
router.push(getWorkspaceRedirectionUrl());
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error || t("something_went_wrong_please_try_again"),
});
}
};
const handleRemoveMember = async (memberId: string) => {
if (!workspaceSlug || !memberId) return;
try {
await removeMemberFromWorkspace(workspaceSlug.toString(), memberId);
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error || t("something_went_wrong_please_try_again"),
});
}
};
const handleRemove = async (memberId: string) => {
if (memberId === currentUser?.id) await handleLeaveWorkspace();
else await handleRemoveMember(memberId);
};
// is the member current logged in user
// const isCurrentUser = memberDetails?.member.id === currentUser?.id;
// is the current logged in user admin
// role change access-
// 1. user cannot change their own role
// 2. only admin or member can change role
// 3. user cannot change role of higher role
if (isEmpty(columns)) return <MembersLayoutLoader />;
return (
<div className="grid border-t border-subtle">
{removeMemberModal && (
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0}
onClose={() => setRemoveMemberModal(null)}
userDetails={{
id: removeMemberModal.member.id,
display_name: removeMemberModal.member.display_name || "",
}}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
)}
<Table<RowData>
columns={columns ?? []}
data={
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tHeadClassName="border-b border-subtle"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-10 text-secondary"
tHeadTrClassName="divide-x-0"
/>
</div>
);
});

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { Collapsible } from "@makeplane/propel/components/collapsible";
import { useTranslation } from "@plane/i18n";
// components
import { CountChip } from "@/components/common/count-chip";
import { MembersSettingsLoader } from "@/components/ui/loader/settings/members";
// hooks
import { useMember } from "@/hooks/store/use-member";
// local imports
import { WorkspaceInvitationsListItem } from "./invitations-list-item";
import { WorkspaceMembersListItem } from "./members-list-item";
export const WorkspaceMembersList = observer(function WorkspaceMembersList(props: {
searchQuery: string;
isAdmin: boolean;
}) {
const { searchQuery, isAdmin } = props;
const [showPendingInvites, setShowPendingInvites] = useState<boolean>(true);
// router
const { workspaceSlug } = useParams();
// store hooks
const {
workspace: {
fetchWorkspaceMembers,
fetchWorkspaceMemberInvitations,
workspaceMemberIds,
getFilteredWorkspaceMemberIds,
getSearchedWorkspaceMemberIds,
workspaceMemberInvitationIds,
getSearchedWorkspaceInvitationIds,
getWorkspaceMemberDetails,
},
} = useMember();
const { t } = useTranslation();
// fetching workspace invitations
useSWR(
workspaceSlug ? `WORKSPACE_MEMBERS_AND_MEMBER_INVITATIONS_${workspaceSlug.toString()}` : null,
workspaceSlug
? async () => {
await fetchWorkspaceMemberInvitations(workspaceSlug.toString());
await fetchWorkspaceMembers(workspaceSlug.toString());
}
: null
);
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
// derived values
const filteredMemberIds = workspaceSlug ? getFilteredWorkspaceMemberIds(workspaceSlug.toString()) : [];
const searchedMemberIds = searchQuery ? getSearchedWorkspaceMemberIds(searchQuery) : filteredMemberIds;
const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery);
const memberDetails = searchedMemberIds
?.map((memberId) => getWorkspaceMemberDetails(memberId))
.sort((a, b) => {
if (a?.is_active && !b?.is_active) return -1;
if (!a?.is_active && b?.is_active) return 1;
return 0;
});
return (
<>
<div className="divide-y-[0.5px] divide-subtle overflow-scroll">
{searchedMemberIds?.length !== 0 && <WorkspaceMembersListItem memberDetails={memberDetails ?? []} />}
{searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && (
<h4 className="mt-16 text-center text-body-xs-regular text-placeholder">{t("no_matching_members")}</h4>
)}
</div>
{isAdmin && searchedInvitationsIds && searchedInvitationsIds.length > 0 && (
<div className="pt-4">
<Collapsible
open={showPendingInvites}
onOpenChange={setShowPendingInvites}
trigger={
<span className="inline-flex items-center gap-2 py-2">
<span className="text-h5-medium">{t("workspace_settings.settings.members.pending_invites")}</span>
{searchedInvitationsIds && <CountChip count={searchedInvitationsIds.length} className="h-5" />}
</span>
}
>
<div className="ml-auto items-center gap-1.5 rounded-md bg-surface-1 py-1.5">
{searchedInvitationsIds?.map((invitationId) => (
<WorkspaceInvitationsListItem key={invitationId} invitationId={invitationId} />
))}
</div>
</Collapsible>
</div>
)}
</>
);
});

View File

@@ -0,0 +1,137 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel, LOGIN_MEDIUM_LABELS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { renderFormattedDate } from "@plane/utils";
import { MemberHeaderColumn } from "@/components/project/member-header-column";
import type { RowData } from "@/components/workspace/settings/member-columns";
import { AccountTypeColumn, NameColumn } from "@/components/workspace/settings/member-columns";
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import type { IMemberFilters } from "@/store/member/utils";
export const useMemberColumns = () => {
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
const { workspaceSlug } = useParams();
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const {
workspace: {
filtersStore: { filters, updateFilters },
},
} = useMember();
const { t } = useTranslation();
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
// oxlint-disable-next-line unicorn/consistent-function-scoping
const isSuspended = (rowData: RowData) => rowData.is_active === false;
// handlers
const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => {
updateFilters(filterUpdates);
};
const columns = [
{
key: "Full name",
content: t("workspace_settings.settings.members.details.full_name"),
thClassName: "text-left",
thRender: () => (
<MemberHeaderColumn
property="full_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => (
<NameColumn
rowData={rowData}
workspaceSlug={workspaceSlug}
isAdmin={isAdmin}
currentUser={currentUser}
setRemoveMemberModal={setRemoveMemberModal}
/>
),
},
{
key: "Display name",
content: t("workspace_settings.settings.members.details.display_name"),
tdRender: (rowData: RowData) => (
<div className={`w-32 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>{rowData.member.display_name}</div>
),
thRender: () => (
<MemberHeaderColumn
property="display_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
},
{
key: "Email address",
content: t("workspace_settings.settings.members.details.email_address"),
tdRender: (rowData: RowData) => (
<div className={`w-48 truncate ${isSuspended(rowData) ? "text-placeholder" : ""}`}>{rowData.member.email}</div>
),
thRender: () => (
<MemberHeaderColumn
property="email"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
},
{
key: "Account type",
content: t("workspace_settings.settings.members.details.account_type"),
thRender: () => (
<MemberHeaderColumn
property="role"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <AccountTypeColumn rowData={rowData} workspaceSlug={workspaceSlug} />,
},
{
key: "Authentication",
content: t("workspace_settings.settings.members.details.authentication"),
tdRender: (rowData: RowData) => {
if (isSuspended(rowData)) return null;
const loginMedium = rowData.member.last_login_medium;
if (!loginMedium) return null;
return <div>{LOGIN_MEDIUM_LABELS[loginMedium]}</div>;
},
},
{
key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"),
tdRender: (rowData: RowData) =>
isSuspended(rowData) ? null : <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
thRender: () => (
<MemberHeaderColumn
property="joining_date"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
};

View File

@@ -0,0 +1,314 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// Plane Imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { ORGANIZATION_SIZE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EditOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspace } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { cn, copyUrlToClipboard, getFileURL, validateWorkspaceName } from "@plane/utils";
// components
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
import { TimezoneSelect } from "@/components/global/timezone-select";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { DeleteWorkspaceSection } from "@/components/workspace/delete-workspace-section";
const defaultValues: Partial<IWorkspace> = {
name: "",
url: "",
organization_size: "2-10",
logo_url: null,
timezone: "UTC",
};
export const WorkspaceDetails = observer(function WorkspaceDetails() {
// states
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
// store hooks
const { currentWorkspace, updateWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
// form info
const {
handleSubmit,
control,
reset,
watch,
formState: { errors },
} = useForm<IWorkspace>({
defaultValues: { ...defaultValues, ...currentWorkspace },
});
// derived values
const workspaceLogo = watch("logo_url");
const onSubmit = async (formData: IWorkspace) => {
if (!currentWorkspace) return;
setIsLoading(true);
const payload: Partial<IWorkspace> = {
name: formData.name,
organization_size: formData.organization_size,
timezone: formData.timezone,
};
try {
await updateWorkspace(currentWorkspace.slug, payload);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Workspace updated successfully",
});
} catch (err: unknown) {
console.error(err);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 300);
}
};
const handleRemoveLogo = async () => {
if (!currentWorkspace) return;
try {
await updateWorkspace(currentWorkspace.slug, {
logo_url: "",
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace picture removed successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
}
};
const handleCopyUrl = () => {
if (!currentWorkspace) return;
void copyUrlToClipboard(`${currentWorkspace.slug}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Workspace URL copied to the clipboard.",
});
return undefined;
})
.catch(() => {
// Silently handle clipboard errors
});
};
useEffect(() => {
if (currentWorkspace) reset({ ...currentWorkspace });
}, [currentWorkspace, reset]);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
if (!currentWorkspace) return null;
return (
<>
<Controller
control={control}
name="logo_url"
render={({ field: { onChange, value } }) => (
<WorkspaceImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
handleRemove={handleRemoveLogo}
onSuccess={(imageUrl) => {
onChange(imageUrl);
setIsImageUploadModalOpen(false);
}}
value={value}
/>
)}
/>
<div className={cn("flex w-full flex-col gap-y-7", { "opacity-60": !isAdmin })}>
<div className="flex items-center gap-5">
<div className="flex shrink-0 flex-col gap-1">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>
{workspaceLogo && workspaceLogo !== "" ? (
<div className="relative flex size-14">
<img
src={getFileURL(workspaceLogo)}
className="absolute top-0 left-0 size-full rounded-md object-cover"
alt="Workspace Logo"
/>
</div>
) : (
<div className="relative grid size-14 place-items-center rounded-md bg-accent-primary text-24 text-on-color uppercase">
{currentWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
</div>
<div className="flex flex-col gap-1">
<div className="mb:-my-5 text-h5-semibold leading-6">{watch("name")}</div>
<button type="button" onClick={handleCopyUrl} className="text-left text-body-xs-regular tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${currentWorkspace.slug}`}</button>
{isAdmin && (
<button
type="button"
className="flex items-center gap-1.5 text-left text-caption-sm-medium text-accent-primary"
onClick={() => setIsImageUploadModalOpen(true)}
>
{workspaceLogo && workspaceLogo !== "" ? (
<>
<EditOutline className="h-3 w-3" />
{t("workspace_settings.settings.general.edit_logo")}
</>
) : (
t("workspace_settings.settings.general.upload_logo")
)}
</button>
)}
</div>
</div>
<div className="flex flex-col gap-7">
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-10 xl:grid-cols-2 2xl:grid-cols-3">
<div className="flex flex-col gap-2">
<h4 className="text-body-sm-medium text-tertiary">{t("workspace_settings.settings.general.name")}</h4>
<Controller
control={control}
name="name"
rules={{
validate: (value) => validateWorkspaceName(value, true),
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="name" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder={t("workspace_settings.settings.general.name")}
disabled={!isAdmin}
/>
</InputGroup>
</Field>
)}
/>
{errors.name && <p className="text-caption-sm-regular text-danger-primary">{errors.name.message}</p>}
</div>
<div className="flex flex-col gap-2">
<h4 className="text-body-sm-medium text-tertiary">
{t("workspace_settings.settings.general.company_size")}
</h4>
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ??
t("workspace_settings.settings.general.errors.company_size.select_a_range")
}
buttonClassName="border border-subtle bg-layer-2 !shadow-none !rounded-md"
input
disabled={!isAdmin}
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex flex-col gap-2">
<h4 className="text-body-sm-medium text-tertiary">{t("workspace_settings.settings.general.url")}</h4>
<Controller
control={control}
name="url"
render={({ field: { onChange, ref } }) => (
<Field name="url" invalid={Boolean(errors.url)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="url"
name="url"
type="url"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${currentWorkspace.slug}`}
onChange={onChange}
ref={ref}
disabled
/>
</InputGroup>
</Field>
)}
/>
</div>
<div className="flex flex-col gap-2">
<h4 className="text-body-sm-medium text-tertiary">
{t("workspace_settings.settings.general.workspace_timezone")}
</h4>
<Controller
name="timezone"
control={control}
render={({ field: { value, onChange } }) => (
<>
<TimezoneSelect value={value} onChange={onChange} disabled={!isAdmin} />
</>
)}
/>
</div>
</div>
</div>
{isAdmin && (
<div className="flex items-center justify-between py-2">
<Button
variant="primary"
size="lg"
onClick={(e) => {
void handleSubmit(onSubmit)(e);
}}
loading={isLoading}
>
{isLoading ? t("updating") : t("workspace_settings.settings.general.update_workspace")}
</Button>
</div>
)}
</div>
{isAdmin && (
<div className="mt-10">
<DeleteWorkspaceSection workspace={currentWorkspace} />
</div>
)}
</>
);
});

View File

@@ -0,0 +1,125 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { SettingsOutline, TickOutline, UserPlusOutline } from "@makeplane/propel/icons";
import { Menu } from "@headlessui/react";
// plane imports
import { EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IWorkspace } from "@plane/types";
import { cn, getFileURL, getUserRole } from "@plane/utils";
type TProps = {
workspace: IWorkspace;
activeWorkspace: IWorkspace | null;
handleItemClick: () => void;
handleWorkspaceNavigation: (workspace: IWorkspace) => void;
handleClose: () => void;
};
const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps) {
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
// router
const { workspaceSlug } = useParams();
// hooks
const { t } = useTranslation();
return (
<Link
key={workspace.id}
href={`/${workspace.slug}`}
onClick={() => {
handleWorkspaceNavigation(workspace);
handleItemClick();
}}
className="w-full"
id={workspace.id}
>
<Menu.Item
as="div"
className={cn("px-4 py-2", {
"bg-layer-transparent-active": workspace.id === activeWorkspace?.id,
"hover:bg-layer-transparent-hover": workspace.id !== activeWorkspace?.id,
})}
>
<div className="flex items-center justify-between gap-1 rounded-sm p-1 text-13 text-primary">
<div className="relative flex w-[80%] items-center justify-start gap-2.5">
<span
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center border-subtle p-2 text-14 font-medium uppercase ${
!workspace?.logo_url && "rounded-md bg-[#026292] text-on-color"
}`}
>
{workspace?.logo_url && workspace.logo_url !== "" ? (
<img
src={getFileURL(workspace.logo_url)}
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
alt={t("workspace_logo")}
/>
) : (
(workspace?.name?.[0] ?? "...")
)}
</span>
<div className="w-[inherit]">
<div
className={`truncate text-left text-13 font-medium text-ellipsis ${workspaceSlug === workspace.slug ? "" : "text-secondary"}`}
>
{workspace.name}
</div>
<div className="flex w-fit gap-2 text-13 text-tertiary capitalize">
<span>{getUserRole(workspace.role)?.toLowerCase() || "guest"}</span>
<div className="m-auto h-1 w-1 rounded-full bg-layer-1/50" />
<span className="capitalize">{t("member", { count: workspace.total_members || 0 })}</span>
</div>
</div>
</div>
{workspace.id === activeWorkspace?.id ? (
<span className="flex-shrink-0 p-1">
<TickOutline className="h-5 w-5 text-primary" />
</span>
) : null}
</div>
{workspace.id === activeWorkspace?.id && (
<>
<div className="mt-2 mb-1 flex gap-2">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings`}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="flex gap-1.5 rounded-md border border-strong bg-layer-2 px-2.5 py-1.5 text-secondary transition-colors hover:border-strong hover:text-secondary hover:shadow-raised-100"
>
<SettingsOutline className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
</Link>
)}
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings/members`}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="flex gap-1.5 rounded-md border border-strong bg-layer-2 px-2.5 py-1.5 text-secondary transition-colors hover:border-strong hover:text-secondary hover:shadow-raised-100"
>
<UserPlusOutline className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">
{t("project_settings.members.invite_members.title")}
</span>
</Link>
)}
</div>
</>
)}
</Menu.Item>
</Link>
);
});
export default SidebarDropdownItem;

View File

@@ -0,0 +1,225 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { PinOutline, UnpinOutline } from "@makeplane/propel/icons";
// plane imports
import type { IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { DragHandle, DropIndicator } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
import { UpgradeBadge } from "@/components/workspace/upgrade-badge";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
// local imports
import { getSidebarNavigationItemIcon } from "./helper";
type TExtendedSidebarItemProps = {
item: IWorkspaceSidebarNavigationItem;
handleOnNavigationItemDrop?: (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => void;
disableDrag?: boolean;
disableDrop?: boolean;
isLastChild: boolean;
};
export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props: TExtendedSidebarItemProps) {
const { item, handleOnNavigationItemDrop, disableDrag = false, disableDrop = false, isLastChild } = props;
const { t } = useTranslation();
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs
const navigationIemRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// nextjs hooks
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { toggleExtendedSidebar } = useAppTheme();
const { data } = useUser();
const { allowPermissions } = useUserPermissions();
const { preferences: workspacePreferences, toggleWorkspaceItem } = useWorkspaceNavigationPreferences();
// derived values
const isPinned = workspacePreferences.items[item.key]?.is_pinned ?? false;
const handleLinkClick = () => toggleExtendedSidebar(true);
useEffect(() => {
const element = navigationIemRef.current;
const dragHandleElement = dragHandleRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => !disableDrag,
dragHandle: dragHandleElement ?? undefined,
getInitialData: () => ({ id: item.key, dragInstanceId: "NAVIGATION" }), // var1
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
!disableDrop && source?.data?.id !== item.key && source?.data?.dragInstanceId === "NAVIGATION",
// oxlint-disable-next-line no-shadow
getData: ({ input, element }) => {
// oxlint-disable-next-line no-shadow
const data = { id: item.key };
// attach instruction for last in list
return attachInstruction(data, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
});
},
onDrag: ({ self }) => {
const extractedInstruction = extractInstruction(self?.data)?.type;
// check if the highlight is to be shown above or below
setInstruction(
extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
const extractedInstruction = extractInstruction(self?.data)?.type;
const currentInstruction = extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined;
if (!currentInstruction) return;
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
if (handleOnNavigationItemDrop)
handleOnNavigationItemDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
},
})
);
}, [isLastChild, handleOnNavigationItemDrop, disableDrag, disableDrop, item.key]);
const itemHref =
item.key === "your_work"
? `/${workspaceSlug.toString()}${item.href}${data?.id}`
: `/${workspaceSlug.toString()}${item.href}`;
const isActive = itemHref === pathname;
const pinNavigationItem = (key: string) => {
toggleWorkspaceItem(key, true);
};
const unPinNavigationItem = (key: string) => {
toggleWorkspaceItem(key, false);
};
const icon = getSidebarNavigationItemIcon(item.key);
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
return (
<div
id={`sidebar-${item.key}`}
className={cn("relative", {
"bg-layer-1 opacity-60": isDragging,
})}
ref={navigationIemRef}
>
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
<div
className={cn(
"group/project-item relative flex w-full items-center rounded-md text-primary hover:bg-surface-2"
)}
id={`${item.key}`}
>
{!disableDrag && (
<Tooltip label={t("drag_to_rearrange")} align="start" disabled={isDragging}>
<button
type="button"
className={cn(
"absolute top-1/2 -left-3 flex -translate-y-1/2 cursor-grab items-center justify-center rounded text-placeholder opacity-0 group-hover/project-item:opacity-100",
{
"cursor-grabbing": isDragging,
"opacity-100": isDragging,
}
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
)}
<SidebarNavItem isActive={isActive}>
<Link href={itemHref} onClick={() => handleLinkClick()} className="group flex-grow">
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
</Link>
<div className="flex items-center gap-2">
{item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
{isPinned ? (
<Tooltip label="Unpin">
<UnpinOutline
className="size-3.5 flex-shrink-0 text-placeholder outline-none hover:text-tertiary"
onClick={() => unPinNavigationItem(item.key)}
/>
</Tooltip>
) : (
<Tooltip label="Pin">
<PinOutline
className="size-3.5 flex-shrink-0 text-placeholder outline-none hover:text-tertiary"
onClick={() => pinNavigationItem(item.key)}
/>
</Tooltip>
)}
</div>
</SidebarNavItem>
</div>
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
</div>
);
});

View File

@@ -0,0 +1,297 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import type {
DragLocationHistory,
ElementDragPayload,
DropTargetRecord,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { orderBy } from "lodash-es";
import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
import {
ChevronRightOutline,
DraftsOutline,
DragDropOutline,
MoreHorizontalOutline,
StarFilled,
} from "@makeplane/propel/icons";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { FavoriteFolderIcon } from "@plane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IFavorite, InstructionType } from "@plane/types";
import { CustomMenu, DropIndicator, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { FavoriteRoot } from "./favorite-items";
import { getCanDrop, getInstructionFromPayload } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
type Props = {
isLastChild: boolean;
favorite: IFavorite;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
};
export function FavoriteFolder(props: Props) {
const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
// store hooks
const { fetchGroupedFavorites } = useFavorite();
const { isMobile } = usePlatformOS();
const { workspaceSlug } = useParams();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
// translation
const { t } = useTranslation();
useEffect(() => {
if (favorite.children === undefined && workspaceSlug) {
fetchGroupedFavorites(workspaceSlug.toString(), favorite.id);
}
}, [favorite.id, favorite.children, workspaceSlug, fetchGroupedFavorites]);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const initialData = { id: favorite.id, isGroup: true, isChild: false };
return combine(
draggable({
element,
getInitialData: () => initialData,
onDragStart: () => setIsDragging(true),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="flex gap-1 rounded-sm bg-surface-1 p-1 pr-2 text-13">
<div className="grid size-5 flex-shrink-0 place-items-center">
<FavoriteFolderIcon />
</div>
<p className="truncate text-13 font-medium text-secondary">{favorite.name}</p>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
onDrop: () => {
setIsDragging(false);
}, // canDrag: () => isDraggable,
}),
dropTargetForElements({
element,
canDrop: ({ source }) => getCanDrop(source, favorite, false),
getData: ({ input, element }) => {
const blockedStates: InstructionType[] = [];
if (!isLastChild) {
blockedStates.push("reorder-below");
}
return attachInstruction(initialData, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
block: blockedStates,
});
},
onDrag: ({ source, self, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source, location }) => {
setInstruction(undefined);
handleDrop(self, source, location);
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging, favorite.id, isLastChild, favorite.id]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return folderToRename ? (
<NewFavoriteFolder
setCreateNewFolder={setFolderToRename}
actionType="rename"
defaultName={favorite.name}
favoriteId={favorite.id}
/>
) : (
<>
<Disclosure key={`${favorite.id}`} ref={elementRef} defaultOpen={false}>
{({ open }) => (
<div
// id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", {
"bg-layer-1 opacity-60": isDragging,
"border-[2px] border-accent-strong": instruction === "make-child",
})}
>
{/* draggable drop top indicator */}
<DropIndicator isVisible={instruction === "reorder-above"} />
<div
className={cn(
"group/project-item relative flex w-full items-center rounded-md px-2 py-1.5 text-primary hover:bg-layer-1-hover",
{
"bg-surface-2": isMenuActive,
}
)}
>
{/* draggable indicator */}
<div className="absolute left-0 hidden h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs bg-surface-2 text-secondary transition-colors group-hover:flex hover:text-primary">
<DragDropOutline className="h-3 w-3" />
</div>
<>
<Tooltip label={`${favorite.name}`} layout="stacked" side="right" sideOffset={40} disabled={isMobile}>
<div className="flex flex-grow truncate">
<Disclosure.Button
as="button"
type="button"
className="flex w-full flex-grow items-center gap-1.5 text-left select-none"
>
<Tooltip
label={favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
align="end"
disabled={isDragging || isMobile}
>
<button
type="button"
className={cn(
"absolute top-1/2 -left-3 hidden -translate-y-1/2 cursor-grab items-center justify-center rounded-sm text-placeholder group-hover/project-item:flex",
{
"cursor-not-allowed opacity-60": favorite.sort_order === null,
"cursor-grabbing": isDragging,
}
)}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
<div className="grid size-5 flex-shrink-0 place-items-center">
<FavoriteFolderIcon />
</div>
<p className="truncate text-13 font-medium text-secondary">{favorite.name}</p>
</Disclosure.Button>
</div>
</Tooltip>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1"
>
<MoreHorizontalOutline className="size-3" />
</span>
}
menuButtonOnClick={() => setIsMenuActive(!isMenuActive)}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<StarFilled className="text-yellow-500 h-3.5 w-3.5" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setFolderToRename(favorite.id)}>
<div className="flex items-center justify-start gap-2">
<DraftsOutline className="h-3.5 w-3.5 stroke-[1.5] text-tertiary" />
<span>Rename Folder</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<Disclosure.Button
as="button"
type="button"
className={cn("hidden rounded-sm p-0.5 group-hover/project-item:inline-block hover:bg-layer-1", {
"inline-block": isMenuActive,
})}
aria-label={t(
open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder"
)}
>
<ChevronRightOutline
className={cn("size-3 flex-shrink-0 text-placeholder transition-transform", {
"rotate-90": open,
})}
/>
</Disclosure.Button>
</>
</div>
{favorite.children && favorite.children.length > 0 && (
<Transition
as="div"
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel as="div" className="mt-1 flex flex-col gap-0.5 px-2">
{orderBy(favorite.children, "sequence", "desc").map((child, index) => (
<FavoriteRoot
key={child.id}
workspaceSlug={workspaceSlug.toString()}
favorite={child}
isLastChild={index === favorite.children.length - 1}
parentId={favorite.id}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleDrop={handleDrop}
/>
))}
</Disclosure.Panel>
</Transition>
)}
{/* draggable drop bottom indicator */}
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
)}
</Disclosure>
</>
);
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// ui
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { DragHandle } from "@plane/ui";
// helper
import { cn } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
sort_order: number | null;
isDragging: boolean;
};
export const FavoriteItemDragHandle = observer(function FavoriteItemDragHandle(props: Props) {
const { sort_order, isDragging } = props;
// store hooks
const { isMobile } = usePlatformOS();
return (
<Tooltip
label={sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
align="end"
disabled={isDragging || isMobile}
>
<div
className={cn(
"absolute top-1/2 -left-3 hidden -translate-y-1/2 cursor-grab items-center justify-center rounded-sm text-placeholder group-hover/project-item:flex",
{
"cursor-not-allowed opacity-60": sort_order === null,
"cursor-grabbing": isDragging,
}
)}
>
<DragHandle className="bg-transparent" />
</div>
</Tooltip>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { LucideIcon } from "lucide-react";
// plane imports
import type { TLogoProps } from "@plane/types";
import { FavoriteFolderIcon } from "@plane/propel/icons";
import { CyclesOutline, ModuleOutline, PagesOutline, ProjectsOutline, ViewsOutline } from "@makeplane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
import { Logo } from "@plane/propel/emoji-icon-picker";
const ICON_MAP: Record<string, React.FC<ISvgIcons> | LucideIcon> = {
page: PagesOutline,
project: ProjectsOutline,
view: ViewsOutline,
module: ModuleOutline,
cycle: CyclesOutline,
folder: FavoriteFolderIcon,
};
type Props = {
type: string;
logo?: TLogoProps;
};
export const FavoriteItemIcon = ({ type, logo }: Props) => {
const Icon = ICON_MAP[type] ?? PagesOutline;
return (
<>
<div className="hidden size-5 items-center justify-center group-hover:flex">
<Icon className="m-auto size-4 flex-shrink-0 stroke-[1.5]" />
</div>
<div className="flex size-5 items-center justify-center group-hover:hidden">
{logo?.in_use ? (
<Logo logo={logo} size={16} type={type === "project" ? "material" : "lucide"} />
) : (
<Icon className="m-auto size-4 flex-shrink-0 stroke-[1.5]" />
)}
</div>
</>
);
};

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { MoreHorizontalOutline, StarFilled } from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { IFavorite } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
type Props = {
ref: React.MutableRefObject<HTMLDivElement | null>;
isMenuActive: boolean;
favorite: IFavorite;
onChange: (value: boolean) => void;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
};
export const FavoriteItemQuickAction = observer(function FavoriteItemQuickAction(props: Props) {
const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props;
// translation
const { t } = useTranslation();
return (
<CustomMenu
customButton={
<span ref={ref} className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1">
<MoreHorizontalOutline className="size-4" />
</span>
}
menuButtonOnClick={() => onChange(!isMenuActive)}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<StarFilled className="text-yellow-500 h-3.5 w-3.5 flex-shrink-0" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
);
});

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
href: string;
title: string;
icon: React.ReactNode;
};
export const FavoriteItemTitle = observer(function FavoriteItemTitle(props: Props) {
const { href, title, icon } = props;
// store hooks
const { toggleSidebar } = useAppTheme();
const { isMobile } = usePlatformOS();
const handleOnClick = () => {
if (isMobile) toggleSidebar();
};
return (
<Tooltip label={title} layout="stacked" side="right" sideOffset={40} disabled={isMobile}>
<Link href={href} className="flex w-full items-center gap-1.5 truncate" draggable onClick={handleOnClick}>
<span className="flex size-5 items-center justify-center">{icon}</span>
<span className="flex-1 truncate text-13 leading-5 font-medium">{title}</span>
</Link>
</Tooltip>
);
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// helpers
import { cn } from "@plane/utils";
type Props = {
children: React.ReactNode;
elementRef: React.RefObject<HTMLDivElement | null>;
isMenuActive?: boolean;
};
export function FavoriteItemWrapper(props: Props) {
const { children, elementRef, isMenuActive = false } = props;
return (
<>
<div
ref={elementRef}
className={cn(
"group/project-item relative flex w-full items-center rounded-md px-2 py-1.5 text-primary hover:bg-layer-transparent-hover",
{
"bg-surface-2": isMenuActive,
}
)}
>
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { FAVORITE_ITEM_LINKS } from "@plane/constants";
import type { IFavorite } from "@plane/types";
export const generateFavoriteItemLink = (workspaceSlug: string, favorite: IFavorite) => {
const entityLinkDetails = FAVORITE_ITEM_LINKS[favorite.entity_type];
if (!entityLinkDetails) {
console.error(`Unrecognized favorite entity type: ${favorite.entity_type}`);
return `/${workspaceSlug}`;
}
if (entityLinkDetails.itemLevel === "workspace") {
return `/${workspaceSlug}/${entityLinkDetails.getLink(favorite)}`;
} else if (entityLinkDetails.itemLevel === "project") {
return `/${workspaceSlug}/projects/${favorite.project_id}/${entityLinkDetails.getLink(favorite)}`;
} else {
return `/${workspaceSlug}`;
}
};

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./favorite-item-drag-handle";
export * from "./favorite-item-icon";
export * from "./favorite-item-quick-action";
export * from "./favorite-item-wrapper";
export * from "./favorite-item-title";
export * from "./helper";

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./common";
export * from "./root";

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 { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import type {
DropTargetRecord,
DragLocationHistory,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import { createRoot } from "react-dom/client";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import type { IFavorite, InstructionType } from "@plane/types";
import { DropIndicator } from "@plane/ui";
// hooks
import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details";
// local imports
import { getCanDrop, getInstructionFromPayload } from "../favorites.helpers";
import { FavoriteItemDragHandle, FavoriteItemQuickAction, FavoriteItemTitle, FavoriteItemWrapper } from "./common";
type Props = {
isLastChild: boolean;
parentId: string | undefined;
workspaceSlug: string;
favorite: IFavorite;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
};
export const FavoriteRoot = observer(function FavoriteRoot(props: Props) {
// props
const { isLastChild, parentId, workspaceSlug, favorite, handleRemoveFromFavorites, handleDrop } = props;
// store hooks
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
//state
const [isDragging, setIsDragging] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
//ref
const elementRef = useRef<HTMLDivElement>(null);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleQuickAction = (value: boolean) => setIsMenuActive(value);
// drag and drop
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const initialData = { id: favorite.id, isGroup: false, isChild: !!parentId, parentId };
return combine(
draggable({
element,
dragHandle: element,
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="rounded-sm bg-surface-1 p-1 pr-2 text-13">
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => getCanDrop(source, favorite, !!parentId),
onDragStart: () => {
setIsDragging(true);
},
getData: ({ input, element }) => {
const blockedStates: InstructionType[] = ["make-child"];
if (!isLastChild) {
blockedStates.push("reorder-below");
}
return attachInstruction(initialData, {
input,
element,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
block: blockedStates,
});
},
onDrag: ({ self, source, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source, location }) => {
setInstruction(undefined);
handleDrop(self, source, location);
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging, isLastChild, favorite.id]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<>
{isDragging && <DropIndicator isVisible={instruction === "reorder-above"} />}
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive}>
<FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
<FavoriteItemQuickAction
favorite={favorite}
ref={actionSectionRef}
isMenuActive={isMenuActive}
onChange={handleQuickAction}
handleRemoveFromFavorites={handleRemoveFromFavorites}
/>
</FavoriteItemWrapper>
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</>
);
});

View File

@@ -0,0 +1,288 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import type {
DragLocationHistory,
DropTargetRecord,
ElementDragPayload,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { orderBy } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronRightOutline, CreateFolderOutline } from "@makeplane/propel/icons";
import { Disclosure, Transition } from "@headlessui/react";
import { IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IFavorite } from "@plane/types";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useFavorite } from "@/hooks/store/use-favorite";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web components
import { FavoriteFolder } from "./favorite-folder";
import { FavoriteRoot } from "./favorite-items";
import type { TargetData } from "./favorites.helpers";
import { getInstructionFromPayload } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
import { IconButton } from "@plane/propel/icon-button";
export const SidebarFavoritesMenu = observer(function SidebarFavoritesMenu() {
// states
const [createNewFolder, setCreateNewFolder] = useState<boolean | string | null>(null);
const [isDragging, setIsDragging] = useState(false);
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { groupedFavorites, deleteFavorite, removeFromFavoriteFolder, reOrderFavorite, moveFavoriteToFolder } =
useFavorite();
// translation
const { t } = useTranslation();
// local storage
const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage<boolean>(IS_FAVORITE_MENU_OPEN, false);
// derived values
const isFavoriteMenuOpen = !!storedValue;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const elementRef = useRef<HTMLDivElement>(null);
const handleMoveToFolder = (sourceId: string, destinationId: string) => {
moveFavoriteToFolder(workspaceSlug.toString(), sourceId, {
parent: destinationId,
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("failed_to_move_favorite"),
});
});
};
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
const isFolder = self.data?.isGroup;
const dropTargets = location?.current?.dropTargets ?? [];
if (!dropTargets || dropTargets.length <= 0) return;
const dropTarget =
dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => target?.data?.isChild) : dropTargets[0];
const dropTargetData = dropTarget?.data as TargetData;
if (!dropTarget || !dropTargetData) return;
const instruction = getInstructionFromPayload(dropTarget, source, location);
const parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId;
const droppedFavId = instruction !== "make-child" ? dropTargetData.id : undefined;
const sourceData = source.data as TargetData;
if (!sourceData.id) return;
if (isFolder) {
// handle move to a new parent folder if dropped on a folder
if (parentId && parentId !== sourceData.parentId) {
handleMoveToFolder(sourceData.id, parentId); /**parent id */
}
// handle reordering at root level
if (droppedFavId) {
if (instruction != "make-child") {
handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
}
}
} else {
//handling reordering for favorites
if (droppedFavId) {
handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
}
}
/**remove if dropped outside and source is a child */
if (!parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id); /**parent null */
}
};
const handleRemoveFromFavorites = (favorite: IFavorite) => {
deleteFavorite(workspaceSlug.toString(), favorite.id)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("favorite_removed_successfully"),
});
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong"),
});
});
};
const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("failed_to_move_favorite"),
});
});
};
const handleReorder = useCallback(
(favoriteId: string, droppedFavId: string, edge: string | undefined) => {
reOrderFavorite(workspaceSlug.toString(), favoriteId, droppedFavId, edge).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("failed_to_reorder_favorite"),
});
});
},
[workspaceSlug, reOrderFavorite, t]
);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
return combine(
dropTargetForElements({
element,
onDragEnter: () => {
setIsDragging(true);
},
onDragLeave: () => {
setIsDragging(false);
},
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ source }) => {
setIsDragging(false);
const sourceId = source?.data?.id as string | undefined;
console.log({ sourceId });
if (!sourceId || !groupedFavorites[sourceId].parent) return;
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef.current, isDragging]);
return (
<>
<Disclosure as="div" defaultOpen ref={containerRef}>
<div
ref={elementRef}
className={cn(
"group/favorites-button flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-placeholder hover:bg-layer-transparent-hover"
)}
>
<Disclosure.Button
as="button"
type="button"
className={cn(
"flex w-full items-center gap-1 text-left text-13 font-semibold whitespace-nowrap text-placeholder",
{
"bg-layer-1 opacity-60": isDragging,
}
)}
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
aria-label={t(
isFavoriteMenuOpen
? "aria_labels.projects_sidebar.close_favorites_menu"
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<span className="text-13 font-semibold">{t("favorites")}</span>
</Disclosure.Button>
<div className="pointer-events-none flex items-center opacity-0 group-hover/favorites-button:pointer-events-auto group-hover/favorites-button:opacity-100">
<Tooltip label={t("create_folder")}>
<IconButton
variant="ghost"
size="sm"
onClick={() => {
setCreateNewFolder(true);
if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
}}
aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")}
icon={CreateFolderOutline}
/>
</Tooltip>
<Disclosure.Button
as="button"
type="button"
className="grid flex-shrink-0 place-items-center rounded-sm p-0.5 hover:bg-layer-transparent-hover"
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
aria-label={t(
isFavoriteMenuOpen
? "aria_labels.projects_sidebar.close_favorites_menu"
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<ChevronRightOutline
className={cn("size-3 flex-shrink-0 transition-all", {
"rotate-90": isFavoriteMenuOpen,
})}
/>
</Disclosure.Button>
</div>
</div>
<Transition
as="div"
show={isFavoriteMenuOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isFavoriteMenuOpen && (
<Disclosure.Panel as="div" className="mt-0.5 flex flex-col gap-0.5" static>
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
{Object.keys(groupedFavorites).length === 0 ? (
<>
<span className="px-8 py-1.5 text-11 font-medium text-placeholder">{t("no_favorites_yet")}</span>
</>
) : (
orderBy(Object.values(groupedFavorites), "sequence", "desc")
.filter((fav) => !fav.parent)
.map((fav, index, { length }) => (
<>
{fav?.is_folder ? (
<FavoriteFolder
favorite={fav}
isLastChild={index === length - 1}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
handleDrop={handleDrop}
/>
) : (
<FavoriteRoot
workspaceSlug={workspaceSlug.toString()}
favorite={fav}
isLastChild={index === length - 1}
parentId={undefined}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleDrop={handleDrop}
/>
)}
</>
))
)}
</Disclosure.Panel>
)}
</Transition>
</Disclosure>
</>
);
});

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import type { IFavorite, InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
export type TargetData = {
id: string;
parentId: string | null;
isGroup: boolean;
isChild: boolean;
};
/**
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
* @param dropTarget dropTarget for which the instruction is required
* @param source the dragging favorite data that is being dragged on the dropTarget
* @param location location includes the data of all the dropTargets the source is being dragged on
* @returns Instruction for dropTarget
*/
export const getInstructionFromPayload = (
dropTarget: TDropTarget,
source: TDropTarget,
location: IPragmaticPayloadLocation
): InstructionType | undefined => {
const dropTargetData = dropTarget?.data as TargetData;
const sourceData = source?.data as TargetData;
const allDropTargets = location?.current?.dropTargets;
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
if (!dropTargetData || !sourceData) return undefined;
let instruction = extractInstruction(dropTargetData)?.type;
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
if (instruction === "instruction-blocked") {
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
}
// if source that is being dragged is a group. A group cannon be a child of any other favorite,
// hence if current instruction is to be a child of dropTarget then reorder-above instead
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
return instruction;
};
/**
* This provides a boolean to indicate if the favorite can be dropped onto the droptarget
* @param source
* @param favorite
* @param isCurrentChild if the dropTarget is a child
* @returns
*/
export const getCanDrop = (source: TDropTarget, favorite: IFavorite | undefined, isCurrentChild: boolean) => {
const sourceData = source?.data;
if (!sourceData) return false;
// a favorite cannot be dropped on to itself
if (sourceData.id === favorite?.id) return false;
// if current dropTarget is a child and the favorite being dropped is a group then don't enable drop
if (isCurrentChild && sourceData.isGroup) return false;
return true;
};

View File

@@ -0,0 +1,163 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import type { SubmitHandler } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// plane helpers
// plane ui
import { FavoriteFolderIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// hooks
import { useFavorite } from "@/hooks/store/use-favorite";
type TForm = {
name: string;
entity_type: string;
parent: string | null;
project_id: string | null;
is_folder: boolean;
};
type TProps = {
setCreateNewFolder: (value: boolean | string | null) => void;
actionType: "create" | "rename";
defaultName?: string;
favoriteId?: string;
};
export const NewFavoriteFolder = observer(function NewFavoriteFolder(props: TProps) {
const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const { addFavorite, updateFavorite, existingFolders } = useFavorite();
// ref
const ref = useRef(null);
// form info
const { handleSubmit, control, setValue, setFocus } = useForm<TForm>({
reValidateMode: "onChange",
defaultValues: {
name: defaultName,
},
});
const handleAddNewFolder: SubmitHandler<TForm> = (formData) => {
if (existingFolders.includes(formData.name))
return setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("folder_already_exists"),
});
formData = {
entity_type: "folder",
is_folder: true,
name: formData.name.trim(),
parent: null,
project_id: null,
};
if (formData.name === "")
return setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("folder_name_cannot_be_empty"),
});
addFavorite(workspaceSlug.toString(), formData)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("favorite_created_successfully"),
});
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong"),
});
});
setCreateNewFolder(false);
setValue("name", "");
};
const handleRenameFolder: SubmitHandler<TForm> = (formData) => {
if (!favoriteId) return;
if (existingFolders.includes(formData.name))
return setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("folder_already_exists"),
});
const payload = {
name: formData.name.trim(),
};
if (formData.name.trim() === "")
return setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("folder_name_cannot_be_empty"),
});
updateFavorite(workspaceSlug.toString(), favoriteId, payload)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("favorite_updated_successfully"),
});
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong"),
});
});
setCreateNewFolder(false);
setValue("name", "");
};
useEffect(() => {
setFocus("name");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useOutsideClickDetector(ref, () => {
setCreateNewFolder(false);
});
return (
<div className="flex items-center gap-1.5 px-2 py-[1px]" ref={ref}>
<FavoriteFolderIcon className="size-4" />
<form onSubmit={handleSubmit(actionType === "create" ? handleAddNewFolder : handleRenameFolder)}>
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field }) => (
<InputGroup size="2xl">
<Input
size="2xl"
placeholder={t("new_folder")}
aria-label={t("aria_labels.projects_sidebar.enter_folder_name")}
{...field}
/>
</InputGroup>
)}
/>
</form>
</div>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
import { HelpOutline, PagesOutline, UserOutline } from "@makeplane/propel/icons";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu } from "@plane/ui";
// components
import { ProductUpdatesModal } from "@/components/global";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { PlaneVersionNumber } from "@/components/global/version-number";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
export const HelpMenuRoot = observer(function HelpMenuRoot() {
// store hooks
const { t } = useTranslation();
const { toggleShortcutsListModal } = usePowerK();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
return (
<>
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
<CustomMenu
customButton={
<AppSidebarItem
variant="button"
item={{
icon: <HelpOutline className="size-5" />,
isActive: isNeedHelpOpen,
}}
/>
}
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
onMenuClose={() => setIsNeedHelpOpen(false)}
placement="bottom-end"
maxHeight="lg"
closeOnSelect
>
<CustomMenu.MenuItem onClick={() => window.open("https://go.plane.so/p-docs", "_blank")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<PagesOutline className="h-3.5 w-3.5 text-secondary" height={14} width={14} />
<span className="text-11">{t("documentation")}</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => window.open("mailto:sales@plane.so", "_blank")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<UserOutline className="h-3.5 w-3.5 text-secondary" width={14} height={14} />
<span className="text-11">{t("contact_sales")}</span>
</div>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-subtle" />
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => toggleShortcutsListModal(true)}
className="justify-sbg-layer-211 flex w-full items-center hover:bg-layer-1"
>
<span className="text-11">{t("keyboard_shortcuts")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => setProductUpdatesModalOpen(true)}
className="justify-sbg-layer-211 flex w-full items-center hover:bg-layer-1"
>
<span className="text-11">{t("whats_new")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => window.open("https://forum.plane.so", "_blank", "noopener,noreferrer")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<span className="text-11">Forum</span>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 border-t border-subtle px-1 pt-2 text-11 text-secondary">
<PlaneVersionNumber />
</div>
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import {
AnalyticsOutline,
ArchiveOutline,
CyclesOutline,
DraftsOutline,
HomeOutline,
InboxOutline,
MultipleStickyOutline,
ProjectsOutline,
ViewsOutline,
YourWorkOutline,
} from "@makeplane/propel/icons";
import { cn } from "@plane/utils";
export const getSidebarNavigationItemIcon = (key: string, className: string = "") => {
switch (key) {
case "home":
return <HomeOutline className={cn("size-4 flex-shrink-0", className)} />;
case "inbox":
return <InboxOutline className={cn("size-4 flex-shrink-0", className)} />;
case "projects":
return <ProjectsOutline className={cn("size-4 flex-shrink-0", className)} />;
case "views":
return <ViewsOutline className={cn("size-4 flex-shrink-0", className)} />;
case "active_cycles":
return <CyclesOutline className={cn("size-4 flex-shrink-0", className)} />;
case "analytics":
return <AnalyticsOutline className={cn("size-4 flex-shrink-0", className)} />;
case "your_work":
return <YourWorkOutline className={cn("size-4 flex-shrink-0", className)} />;
case "drafts":
return <DraftsOutline className={cn("size-4 flex-shrink-0", className)} />;
case "archives":
return <ArchiveOutline className={cn("size-4 flex-shrink-0", className)} />;
case "stickies":
return <MultipleStickyOutline className={cn("size-4 flex-shrink-0", className)} />;
}
};

View File

@@ -0,0 +1,211 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
CyclesOutline,
IntakeOutline,
ModuleOutline,
PagesOutline,
ViewsOutline,
WorkItemsOutline,
} from "@makeplane/propel/icons";
import type { EUserProjectRoles } from "@plane/types";
// plane ui
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
export type TNavigationItem = {
name: string;
href: string;
icon: React.ElementType;
access: EUserPermissions[] | EUserProjectRoles[];
shouldRender: boolean;
sortOrder: number;
i18n_key: string;
key: string;
};
type TProjectItemsProps = {
workspaceSlug: string;
projectId: string;
additionalNavigationItems?: (workspaceSlug: string, projectId: string) => TNavigationItem[];
};
export const ProjectNavigation = observer(function ProjectNavigation(props: TProjectItemsProps) {
const { workspaceSlug, projectId, additionalNavigationItems } = props;
const { workItem: workItemIdentifierFromRoute } = useParams();
// store hooks
const { t } = useTranslation();
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar, toggleSidebar } = useAppTheme();
const { getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const {
issue: { getIssueIdByIdentifier, getIssueById },
} = useIssueDetail();
// pathname
const pathname = usePathname();
// derived values
const workItemId = workItemIdentifierFromRoute
? getIssueIdByIdentifier(workItemIdentifierFromRoute?.toString())
: undefined;
const workItem = workItemId ? getIssueById(workItemId) : undefined;
const project = getPartialProjectById(projectId);
// handlers
const handleProjectClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
// close the extended sidebar if it is open
if (isExtendedProjectSidebarOpened) {
toggleExtendedProjectSidebar(false);
}
};
const baseNavigation = useCallback(
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
{
i18n_key: "sidebar.work_items",
key: "work_items",
name: "Work items",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: WorkItemsOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: true,
sortOrder: 1,
},
{
i18n_key: "sidebar.cycles",
key: "cycles",
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: CyclesOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: project?.cycle_view ?? false,
sortOrder: 2,
},
{
i18n_key: "sidebar.modules",
key: "modules",
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: ModuleOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: project?.module_view ?? false,
sortOrder: 3,
},
{
i18n_key: "sidebar.views",
key: "views",
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: ViewsOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: project?.issue_views_view ?? false,
sortOrder: 4,
},
{
i18n_key: "sidebar.pages",
key: "pages",
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: PagesOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: project?.page_view ?? false,
sortOrder: 5,
},
{
i18n_key: "sidebar.intake",
key: "intake",
name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/intake`,
icon: IntakeOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: project?.inbox_view ?? false,
sortOrder: 6,
},
],
[project]
);
// memoized navigation items and adding additional navigation items
const navigationItemsMemo = useMemo(() => {
const navigationItems = (workspaceSlug: string, projectId: string): TNavigationItem[] => {
const navItems = baseNavigation(workspaceSlug, projectId);
if (additionalNavigationItems) {
navItems.push(...additionalNavigationItems(workspaceSlug, projectId));
}
return navItems;
};
// sort navigation items by sortOrder
const sortedNavigationItems = navigationItems(workspaceSlug, projectId).sort(
(a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)
);
return sortedNavigationItems;
}, [workspaceSlug, projectId, baseNavigation, additionalNavigationItems]);
const isActive = useCallback(
(item: TNavigationItem) => {
// work item condition
const workItemCondition = workItemId && workItem && !workItem?.is_epic && workItem?.project_id === projectId;
// epic condition
const epicCondition = workItemId && workItem && workItem?.is_epic && workItem?.project_id === projectId;
// is active
const isWorkItemActive = item.key === "work_items" && workItemCondition;
const isEpicActive = item.key === "epics" && epicCondition;
// pathname condition
const isPathnameActive = pathname.includes(item.href);
// return
return isWorkItemActive || isEpicActive || isPathnameActive;
},
[pathname, workItem, workItemId, projectId]
);
if (!project) return null;
return (
<>
{navigationItemsMemo.map((item) => {
if (!item.shouldRender) return;
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project.id);
if (!hasAccess) return null;
const shouldShowCount = item.key === "intake" && (project.intake_count ?? 0) > 0;
return (
<Link key={item.key} href={item.href} onClick={handleProjectClick}>
<SidebarNavItem isActive={!!isActive(item)}>
<div className="flex w-full items-center justify-between gap-1.5 py-[1px]">
<div className="flex items-center gap-1.5">
<item.icon
className={`size-4 flex-shrink-0 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`}
/>
<span className="text-11 font-medium">{t(item.i18n_key)}</span>
</div>
{shouldShowCount && <span className="text-11 font-medium text-tertiary">{project.intake_count}</span>}
</div>
</SidebarNavItem>
</Link>
);
})}
</>
);
});

View File

@@ -0,0 +1,495 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { createRoot } from "react-dom/client";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import {
ArchiveOutline,
ChevronRightOutline,
LinkOutline,
LogOutOutline,
MoreHorizontalOutline,
SettingsOutline,
ShareAltOutline,
} from "@makeplane/propel/icons";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { IconButton } from "@plane/propel/icon-button";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigation-utils";
import { useTabPreferences } from "@/components/navigation/use-tab-preferences";
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
import { PublishProjectModal } from "@/components/project/publish-project/modal";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../../issues/issue-layouts/utils";
import { ProjectNavigation } from "./project-navigation";
import { useNavigationItems } from "@/components/navigation/use-navigation-items";
type Props = {
projectId: string;
handleCopyText: () => void;
handleOnProjectDrop?: (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => void;
projectListType: "JOINED" | "FAVORITES";
disableDrag?: boolean;
disableDrop?: boolean;
isLastChild: boolean;
renderInExtendedSidebar?: boolean;
};
export const SidebarProjectsListItem = observer(function SidebarProjectsListItem(props: Props) {
const {
projectId,
handleCopyText,
disableDrag,
disableDrop,
isLastChild,
handleOnProjectDrop,
projectListType,
renderInExtendedSidebar = false,
} = props;
// store hooks
const { t } = useTranslation();
const { getPartialProjectById } = useProject();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette();
const { preferences: projectPreferences } = useProjectNavigationPreferences();
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar, toggleAnySidebarDropdown } = useAppTheme();
// states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
const [publishModalOpen, setPublishModal] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const isProjectListOpen = getIsProjectListOpen(projectId);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs
const actionSectionRef = useRef<HTMLButtonElement | null>(null);
const projectRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// router
const { workspaceSlug, projectId: URLProjectId } = useParams();
const router = useRouter();
// derived values
const project = getPartialProjectById(projectId);
// Get available navigation items for this project
const navigationItems = useNavigationItems({
workspaceSlug: workspaceSlug.toString(),
projectId,
project,
allowPermissions,
});
const availableTabKeys = navigationItems.map((item) => item.key);
// Get preferences from hook
const { tabPreferences } = useTabPreferences(workspaceSlug.toString(), projectId);
const defaultTabKey = tabPreferences.defaultTab;
// Validate that the default tab is available
const validatedDefaultTabKey = availableTabKeys.includes(defaultTabKey) ? defaultTabKey : DEFAULT_TAB_KEY;
const defaultTabUrl = project ? getTabUrl(workspaceSlug.toString(), project.id, validatedDefaultTabKey) : "";
// toggle project list open
const setIsProjectListOpen = useCallback(
(value: boolean) => toggleProjectListOpen(projectId, value),
[projectId, toggleProjectListOpen]
);
// auth
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
project?.id
);
const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
project?.id
);
const handleLeaveProject = () => {
setLeaveProjectModal(true);
};
useEffect(() => {
const element = projectRef.current;
const dragHandleElement = dragHandleRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => !disableDrag,
dragHandle: dragHandleElement ?? undefined,
getInitialData: () => ({ id: projectId, dragInstanceId: "PROJECTS" }),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// Add a custom drag image
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="flex items-center rounded-sm bg-surface-1 p-1 pr-2 text-13">
<div className="grid size-4 flex-shrink-0 place-items-center">
{project && <Logo logo={project?.logo_props} />}
</div>
<p className="truncate text-secondary">{project?.name}</p>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
!disableDrop && source?.data?.id !== projectId && source?.data?.dragInstanceId === "PROJECTS",
// oxlint-disable-next-line no-shadow
getData: ({ input, element }) => {
const data = { id: projectId };
// attach instruction for last in list
return attachInstruction(data, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
});
},
onDrag: ({ self }) => {
const extractedInstruction = extractInstruction(self?.data)?.type;
// check if the highlight is to be shown above or below
setInstruction(
extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
const extractedInstruction = extractInstruction(self?.data)?.type;
const currentInstruction = extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined;
if (!currentInstruction) return;
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
handleOnProjectDrop?.(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
highlightIssueOnDrop(`sidebar-${sourceId}-${projectListType}`);
},
})
);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps
}, [projectId, isLastChild, projectListType, handleOnProjectDrop]);
useEffect(() => {
if (isMenuActive) toggleAnySidebarDropdown(true);
else toggleAnySidebarDropdown(false);
}, [isMenuActive, toggleAnySidebarDropdown]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (URLProjectId === project?.id) {
setIsProjectListOpen(true);
// Scroll to active project
if (projectRef.current) {
timeoutId = setTimeout(() => {
if (projectRef.current) {
scrollIntoView(projectRef.current, {
behavior: "smooth",
block: "center",
scrollMode: "if-needed",
});
}
}, 200);
}
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [URLProjectId, project?.id, setIsProjectListOpen]);
if (!project) return null;
const isAccordionMode = projectPreferences.navigationMode === "ACCORDION";
const handleItemClick = () => {
if (projectPreferences.navigationMode === "ACCORDION") {
setIsProjectListOpen(!isProjectListOpen);
} else {
router.push(defaultTabUrl);
}
// close the extended sidebar if it is open
if (isExtendedProjectSidebarOpened && !isAccordionMode) {
toggleExtendedProjectSidebar(false);
}
};
const shouldHighlightProject = URLProjectId === project?.id && projectPreferences.navigationMode !== "ACCORDION";
return (
<>
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => setPublishModal(false)} />
<LeaveProjectModal project={project} isOpen={leaveProjectModalOpen} onClose={() => setLeaveProjectModal(false)} />
<Disclosure key={`${project.id}_${URLProjectId}`} defaultOpen={isProjectListOpen} as="div">
<div
id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", {
"bg-layer-1 opacity-60": isDragging,
})}
ref={projectRef}
>
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
<div
className={cn(
"group/project-item relative flex w-full items-center rounded-md px-2 py-1.5 text-primary hover:bg-layer-transparent-hover",
{
"bg-surface-2": isMenuActive,
"bg-layer-transparent-active": shouldHighlightProject,
}
)}
id={`${project?.id}`}
>
{!disableDrag && (
<Tooltip
label={project.sort_order === null ? t("join_the_project_to_rearrange") : t("drag_to_rearrange")}
align="end"
disabled={isDragging || isMobile}
>
<button
type="button"
className={cn(
"absolute top-1/2 -left-3 hidden -translate-y-1/2 cursor-grab items-center justify-center rounded-sm text-placeholder group-hover/project-item:flex",
{
"cursor-not-allowed opacity-60": project.sort_order === null,
"cursor-grabbing": isDragging,
flex: isMenuActive || renderInExtendedSidebar,
}
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
)}
<>
<ControlLink href={defaultTabUrl} className="flex flex-grow truncate" onClick={handleItemClick}>
{isAccordionMode ? (
<Disclosure.Button
as="button"
type="button"
className={cn("flex w-full flex-grow items-center gap-1.5 text-left select-none", {})}
aria-label={
isProjectListOpen
? t("aria_labels.projects_sidebar.close_project_menu")
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="grid size-4 flex-shrink-0 place-items-center">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-13 font-medium text-secondary">{project.name}</p>
</Disclosure.Button>
) : (
<div className="flex w-full flex-grow items-center gap-1.5 text-left select-none">
<div className="grid size-4 flex-shrink-0 place-items-center">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-13 font-medium text-secondary">{project.name}</p>
</div>
)}
</ControlLink>
<div className="flex items-center gap-1">
<CustomMenu
customButton={
<IconButton
ref={actionSectionRef}
variant="ghost"
size="sm"
icon={MoreHorizontalOutline}
onClick={() => setIsMenuActive(!isMenuActive)}
className="text-placeholder"
/>
}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
onMenuClose={() => setIsMenuActive(false)}
>
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
<CustomMenu.MenuItem
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
>
<span className="flex items-center justify-start gap-2">
<Star
className={cn("h-3.5 w-3.5 ", {
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
})}
/>
<span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span>
</span>
</CustomMenu.MenuItem>
)} */}
{/* publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm text-secondary transition-all duration-300 hover:bg-layer-1">
<ShareAltOutline className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
<div>{t("publish_project")}</div>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("copy_link")}</span>
</span>
</CustomMenu.MenuItem>
{isAuthorized && (
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
}}
>
<div className="flex cursor-pointer items-center justify-start gap-2">
<ArchiveOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
}}
>
<div className="flex cursor-pointer items-center justify-start gap-2">
<SettingsOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
{/* leave project */}
{!isAuthorized && (
<CustomMenu.MenuItem onClick={handleLeaveProject}>
<div className="flex items-center justify-start gap-2">
<LogOutOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
{isAccordionMode && (
<IconButton
variant="ghost"
size="sm"
icon={ChevronRightOutline}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
className={cn("hidden text-placeholder group-hover/project-item:inline-flex", {
"inline-flex": isMenuActive,
})}
iconClassName={cn("transition-transform", {
"rotate-90": isProjectListOpen,
})}
aria-label={t(
isProjectListOpen
? "aria_labels.projects_sidebar.close_project_menu"
: "aria_labels.projects_sidebar.open_project_menu"
)}
/>
)}
</div>
</>
</div>
{isAccordionMode && (
<Transition
as="div"
show={isProjectListOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isProjectListOpen && (
<Disclosure.Panel as="div" className="relative mt-1 mb-1.5 flex flex-col gap-0.5 pl-6">
<div className="absolute top-0 bottom-1 left-[15px] w-[1px] bg-layer-3" />
<ProjectNavigation workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Disclosure.Panel>
)}
</Transition>
)}
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
</div>
</Disclosure>
</>
);
});

View File

@@ -0,0 +1,290 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, useRef, useEffect } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { AddOutline, ChevronRightOutline, MoreHorizontalOutline } from "@makeplane/propel/icons";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { Loader } from "@plane/ui";
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@plane/utils";
// components
import { CreateProjectModal } from "@/components/project/create-project-modal";
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane web imports
import type { TProject } from "@plane/types";
// local imports
import { SidebarProjectsListItem } from "./projects-list-item";
export const SidebarProjectsList = observer(function SidebarProjectsList() {
// states
const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(true);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
// refs
const containerRef = useRef<HTMLDivElement | null>(null);
// store hooks
const { t } = useTranslation();
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { preferences: projectPreferences } = useProjectNavigationPreferences();
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme();
const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
// router params
const { workspaceSlug } = useParams();
const pathname = usePathname();
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// Compute limited projects for main sidebar
const displayedProjects = projectPreferences.showLimitedProjects
? joinedProjects.slice(0, projectPreferences.limitedProjectsCount)
: joinedProjects;
// Check if there are more projects to show
const hasMoreProjects =
projectPreferences.showLimitedProjects && joinedProjects.length > projectPreferences.limitedProjectsCount;
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
};
const handleOnProjectDrop = (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => {
if (!sourceId || !destinationId || !workspaceSlug) return;
if (sourceId === destinationId) return;
const joinedProjectsList: TProject[] = [];
joinedProjects.map((projectId) => {
const projectDetails = getPartialProjectById(projectId);
if (projectDetails) joinedProjectsList.push(projectDetails);
});
const sourceIndex = joinedProjects.indexOf(sourceId);
const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId);
if (joinedProjectsList.length <= 0) return;
const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList);
if (updatedSortOrder != undefined)
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong"),
});
});
};
/**
* Implementing scroll animation styles based on the scroll length of the container
*/
useEffect(() => {
const handleScroll = () => {
if (containerRef.current) {
const scrollTop = containerRef.current.scrollTop;
setIsScrolled(scrollTop > 0);
}
};
const currentContainerRef = containerRef.current;
if (currentContainerRef) {
currentContainerRef.addEventListener("scroll", handleScroll);
}
return () => {
if (currentContainerRef) {
currentContainerRef.removeEventListener("scroll", handleScroll);
}
};
}, [containerRef]);
useEffect(() => {
const element = containerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
canScroll: ({ source }) => source?.data?.dragInstanceId === "PROJECTS",
getAllowedAxis: () => "vertical",
})
);
}, [containerRef]);
const toggleListDisclosure = (isOpen: boolean) => {
setIsAllProjectsListOpen(isOpen);
localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
};
useEffect(() => {
if (pathname.includes("projects")) {
setIsAllProjectsListOpen(true);
localStorage.setItem("isAllProjectsListOpen", "true");
}
}, [pathname]);
return (
<>
{workspaceSlug && (
<CreateProjectModal
isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)}
setToFavorite={false}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<div
ref={containerRef}
className={cn({
"border-t border-strong": isScrolled,
})}
>
<>
<Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
<div className="group flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-placeholder hover:bg-layer-transparent-hover">
<Disclosure.Button
as="button"
type="button"
className="flex w-full items-center gap-1 text-left text-13 font-semibold whitespace-nowrap text-placeholder"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
aria-label={t(
isAllProjectsListOpen
? "aria_labels.projects_sidebar.close_projects_menu"
: "aria_labels.projects_sidebar.open_projects_menu"
)}
>
<span className="text-13 font-semibold">{t("projects")}</span>
</Disclosure.Button>
<div className="flex items-center gap-1">
{isAuthorizedUser && (
<Tooltip label={t("create_project")}>
<IconButton
variant="ghost"
size="sm"
icon={AddOutline}
onClick={() => {
setIsProjectModalOpen(true);
}}
className="hidden text-placeholder group-hover:inline-flex"
aria-label={t("aria_labels.projects_sidebar.create_new_project")}
/>
</Tooltip>
)}
<IconButton
variant="ghost"
size="sm"
icon={ChevronRightOutline}
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
className="text-placeholder"
iconClassName={cn("transition-transform", {
"rotate-90": isAllProjectsListOpen,
})}
aria-label={t(
isAllProjectsListOpen
? "aria_labels.projects_sidebar.close_projects_menu"
: "aria_labels.projects_sidebar.open_projects_menu"
)}
/>
</div>
</div>
<Transition
as="div"
show={isAllProjectsListOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{loader === "init-loader" && (
<Loader className="w-full space-y-1.5">
{Array.from({ length: 4 }).map((_, index) => (
<Loader.Item key={index} height="28px" />
))}
</Loader>
)}
{isAllProjectsListOpen && (
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
<>
{displayedProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === displayedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
/>
))}
{hasMoreProjects && (
<SidebarNavItem>
<button
type="button"
onClick={() => toggleExtendedProjectSidebar()}
className="flex flex-grow items-center gap-1.5 text-13 font-medium text-tertiary"
id="extended-project-sidebar-toggle"
aria-label={t(
isExtendedProjectSidebarOpened
? "aria_labels.app_sidebar.close_extended_sidebar"
: "aria_labels.app_sidebar.open_extended_sidebar"
)}
>
<MoreHorizontalOutline className="size-4 flex-shrink-0" />
<span>{isExtendedProjectSidebarOpened ? "Hide" : "More"}</span>
</button>
</SidebarNavItem>
)}
</>
</Disclosure.Panel>
)}
</Transition>
</Disclosure>
</>
{isAuthorizedUser && joinedProjects?.length === 0 && (
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-13 leading-5 font-medium text-secondary hover:bg-surface-2"
onClick={() => {
toggleCreateProjectModal(true);
}}
>
{t("add_project")}
</button>
)}
</div>
</>
);
});

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 { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { AddWorkItemOutline } from "@makeplane/propel/icons";
import type { TIssue } from "@plane/types";
// components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
import { SidebarAddButton } from "@/components/sidebar/add-button";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import useLocalStorage from "@/hooks/use-local-storage";
export const SidebarQuickActions = observer(function SidebarQuickActions() {
const { t } = useTranslation();
// states
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
const [_isDraftButtonOpen, setIsDraftButtonOpen] = useState(false);
// refs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const timeoutRef = useRef<any>(undefined);
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// store hooks
const { toggleCreateIssueModal } = useCommandPalette();
const { joinedProjectIds } = useProject();
const { allowPermissions } = useUserPermissions();
// local storage
const { storedValue, setValue } = useLocalStorage<Record<string, Partial<TIssue>>>("draftedIssue", {});
// derived values
const canCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const disabled = joinedProjectIds.length === 0 || !canCreateIssue;
const workspaceDraftIssue = workspaceSlug ? (storedValue?.[workspaceSlug] ?? undefined) : undefined;
const handleMouseEnter = () => {
// if enter before time out clear the timeout
if (timeoutRef?.current) {
clearTimeout(timeoutRef.current);
}
setIsDraftButtonOpen(true);
};
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setIsDraftButtonOpen(false);
}, 300);
};
const removeWorkspaceDraftIssue = () => {
const draftIssues = storedValue ?? {};
if (workspaceSlug && draftIssues[workspaceSlug]) delete draftIssues[workspaceSlug];
setValue(draftIssues);
return Promise.resolve();
};
return (
<>
<CreateUpdateIssueModal
isOpen={isDraftIssueModalOpen}
onClose={() => setIsDraftIssueModalOpen(false)}
data={workspaceDraftIssue ?? {}}
onSubmit={() => removeWorkspaceDraftIssue()}
fetchIssueDetails={false}
isDraft
/>
<div className="flex cursor-pointer items-center justify-between gap-2">
<SidebarAddButton
label={
<>
<AddWorkItemOutline className="size-4" />
<span className="max-w-[145px] truncate text-13 font-medium">{t("sidebar.new_work_item")}</span>
</>
}
onClick={() => toggleCreateIssueModal(true)}
disabled={disabled}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
</div>
</>
);
});

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import type { IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { joinUrlPath } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane web imports
import { getSidebarNavigationItemIcon } from "@/components/workspace/sidebar/helper";
type Props = {
item: IWorkspaceSidebarNavigationItem;
additionalRender?: (itemKey: string, workspaceSlug: string) => ReactNode;
additionalStaticItems?: string[];
};
export const SidebarItemBase = observer(function SidebarItemBase({
item,
additionalRender,
additionalStaticItems,
}: Props) {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { allowPermissions } = useUserPermissions();
const { isWorkspaceItemPinned } = useWorkspaceNavigationPreferences();
const { data } = useUser();
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const handleLinkClick = () => {
if (window.innerWidth < 768) toggleSidebar();
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
};
const staticItems = [
"home",
"pi_chat",
"projects",
"your_work",
"stickies",
"drafts",
...(additionalStaticItems || []),
];
const slug = workspaceSlug?.toString() || "";
if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null;
const isPinned = isWorkspaceItemPinned(item.key);
if (!isPinned && !staticItems.includes(item.key)) return null;
const itemHref =
item.key === "your_work" && data?.id ? joinUrlPath(slug, item.href, data?.id) : joinUrlPath(slug, item.href);
const icon = getSidebarNavigationItemIcon(item.key);
return (
<Link href={itemHref} onClick={handleLinkClick}>
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{additionalRender?.(item.key, slug)}
</SidebarNavItem>
</Link>
);
});

View File

@@ -0,0 +1,183 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { ChevronRightOutline, MoreHorizontalOutline } from "@makeplane/propel/icons";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import {
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// store hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import useLocalStorage from "@/hooks/use-local-storage";
import {
usePersonalNavigationPreferences,
useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences";
import { SidebarItemBase } from "./sidebar-item";
export const SidebarMenuItems = observer(function SidebarMenuItems() {
// routers
const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage<boolean>(
"is_workspace_menu_open",
true
);
// store hooks
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
// hooks
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
const { preferences: workspacePreferences } = useWorkspaceNavigationPreferences();
// translation
const { t } = useTranslation();
const toggleListDisclosure = (isOpen: boolean) => {
toggleWorkspaceMenu(isOpen);
};
// Filter static navigation items based on personal preferences
const filteredStaticNavigationItems = useMemo(() => {
const items = [...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS];
const personalItems: Array<(typeof items)[0] & { sort_order: number }> = [];
// Add personal items based on preferences with their sort_order
const stickiesItem = WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["stickies"];
if (personalPreferences.items.stickies?.enabled && stickiesItem) {
personalItems.push({
...stickiesItem,
sort_order: personalPreferences.items.stickies.sort_order,
});
}
if (personalPreferences.items.your_work?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]) {
personalItems.push({
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"],
sort_order: personalPreferences.items.your_work.sort_order,
});
}
if (personalPreferences.items.drafts?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"]) {
personalItems.push({
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"],
sort_order: personalPreferences.items.drafts.sort_order,
});
}
// Sort personal items by sort_order
personalItems.sort((a, b) => a.sort_order - b.sort_order);
// Merge static items with sorted personal items
return [...items, ...personalItems];
}, [personalPreferences]);
const sortedNavigationItems = useMemo(
() =>
// oxlint-disable-next-line oxc/no-map-spread
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const preference = workspacePreferences.items[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[workspacePreferences]
);
return (
<>
<div className="flex flex-col gap-0.5">
{filteredStaticNavigationItems.map((item, _index) => (
// oxlint-disable-next-line react/no-array-index-key
<SidebarItemBase key={`static_${_index}`} item={item} />
))}
</div>
<Disclosure as="div" className="flex flex-col" defaultOpen={!!isWorkspaceMenuOpen}>
<div className="group flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-placeholder hover:bg-layer-transparent-hover">
<Disclosure.Button
as="button"
type="button"
className="flex w-full items-center gap-1 text-left text-13 font-semibold whitespace-nowrap text-placeholder"
onClick={() => toggleListDisclosure(!isWorkspaceMenuOpen)}
aria-label={t(
isWorkspaceMenuOpen
? "aria_labels.app_sidebar.close_workspace_menu"
: "aria_labels.app_sidebar.open_workspace_menu"
)}
>
<span className="text-13 font-semibold">{t("common.workspace")}</span>
</Disclosure.Button>
<div className="pointer-events-none flex items-center opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
<Disclosure.Button
as="button"
type="button"
className="flex-shrink-0 rounded-sm p-0.5 hover:bg-layer-1"
onClick={() => toggleListDisclosure(!isWorkspaceMenuOpen)}
aria-label={t(
isWorkspaceMenuOpen
? "aria_labels.app_sidebar.close_workspace_menu"
: "aria_labels.app_sidebar.open_workspace_menu"
)}
>
<ChevronRightOutline
className={cn("size-3 flex-shrink-0 transition-all", {
"rotate-90": isWorkspaceMenuOpen,
})}
/>
</Disclosure.Button>
</div>
</div>
<Transition
as="div"
show={!!isWorkspaceMenuOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isWorkspaceMenuOpen && (
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
<>
{WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
// oxlint-disable-next-line react/no-array-index-key
<SidebarItemBase key={`static_${_index}`} item={item} />
))}
{sortedNavigationItems.map((item, _index) => (
// oxlint-disable-next-line react/no-array-index-key
<SidebarItemBase key={`dynamic_${_index}`} item={item} />
))}
<SidebarNavItem>
<button
type="button"
onClick={() => toggleExtendedSidebar()}
className="flex flex-grow items-center gap-1.5 text-13 font-medium text-tertiary"
id="extended-sidebar-toggle"
aria-label={t(
isExtendedSidebarOpened
? "aria_labels.app_sidebar.close_extended_sidebar"
: "aria_labels.app_sidebar.open_extended_sidebar"
)}
>
<MoreHorizontalOutline className="size-4 flex-shrink-0" />
<span>{isExtendedSidebarOpened ? "Hide" : "More"}</span>
</button>
</SidebarNavItem>
</>
</Disclosure.Panel>
)}
</Transition>
</Disclosure>
</>
);
});

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { EUserWorkspaceRoles } from "@plane/types";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useUserPermissions } from "@/hooks/store/user";
export interface SidebarUserMenuItemProps {
item: {
key: string;
href: string;
access: EUserWorkspaceRoles[];
labelTranslationKey: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Icon: any;
};
draftIssueCount: number;
}
export const SidebarUserMenuItem = observer(function SidebarUserMenuItem(props: SidebarUserMenuItemProps) {
const { item, draftIssueCount } = props;
// nextjs hooks
const { workspaceSlug } = useParams();
const pathname = usePathname();
// package hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleSidebar } = useAppTheme();
const isActive = pathname === item.href;
if (item.key === "drafts" && draftIssueCount === 0) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) return null;
const handleLinkClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
};
return (
<Link href={item.href} onClick={handleLinkClick}>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
<item.Icon className="size-4 flex-shrink-0" />
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{item.key === "notifications" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug.toString()} />}
</SidebarNavItem>
</Link>
);
});

View File

@@ -0,0 +1,150 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { LogOutOutline, SettingsOutline } from "@makeplane/propel/icons";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { GOD_MODE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { CustomMenu } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// components
import { CoverImage } from "@/components/common/cover-image";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
export const UserMenuRoot = observer(function UserMenuRoot() {
// states
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
// router
const router = useRouter();
// store hooks
const { toggleAnySidebarDropdown } = useAppTheme();
const { data: currentUser } = useUser();
const { signOut } = useUser();
const { toggleProfileSettingsModal } = useCommandPalette();
// derived values
const isUserInstanceAdmin = false;
// translation
const { t } = useTranslation();
const handleSignOut = () => {
signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("auth.sign_out.toast.error.title"),
message: t("auth.sign_out.toast.error.message"),
})
);
};
// Toggle sidebar dropdown state when menu is open
useEffect(() => {
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
else toggleAnySidebarDropdown(false);
}, [isUserMenuOpen, toggleAnySidebarDropdown]);
return (
<CustomMenu
className="flex items-center"
customButton={
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
alt={currentUser?.display_name}
fallback={currentUser?.display_name?.[0]?.toUpperCase()}
src={getFileURL(currentUser?.avatar_url ?? "")}
size="xs"
/>
),
isActive: isUserMenuOpen,
}}
/>
}
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)}
placement="bottom-end"
maxHeight="2xl"
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
closeOnSelect
>
<div className="relative h-29 w-full rounded-lg">
<CoverImage
src={currentUser?.cover_image_url ?? undefined}
alt={currentUser?.display_name}
className="h-29 w-full rounded-lg"
showDefaultWhenEmpty
/>
<div className="absolute inset-0 bg-layer-1/50" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col items-center gap-y-2">
<div>
<Avatar
alt={currentUser?.display_name}
fallback={currentUser?.display_name?.[0]?.toUpperCase()}
src={getFileURL(currentUser?.avatar_url ?? "")}
size="xl"
/>
</div>
<div className="text-center">
<p className="text-body-sm-medium">
{currentUser?.first_name} {currentUser?.last_name}
</p>
<p className="text-caption-md-regular">{currentUser?.email}</p>
</div>
</div>
</div>
</div>
<div>
<CustomMenu.MenuItem
onClick={() =>
toggleProfileSettingsModal({
activeTab: "general",
isOpen: true,
})
}
className="flex items-center gap-2"
>
<SettingsOutline className="size-3.5 shrink-0" />
{t("settings")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() =>
toggleProfileSettingsModal({
activeTab: "preferences",
isOpen: true,
})
}
className="flex items-center gap-2"
>
<SettingsOutline className="size-3.5 shrink-0" />
{t("preferences")}
</CustomMenu.MenuItem>
</div>
<CustomMenu.MenuItem onClick={handleSignOut} className="flex items-center gap-2">
<LogOutOutline className="size-3.5 shrink-0" />
{t("sign_out")}
</CustomMenu.MenuItem>
{isUserInstanceAdmin && (
<CustomMenu.MenuItem
onClick={() => router.push(GOD_MODE_URL)}
className="bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary"
>
{t("enter_god_mode")}
</CustomMenu.MenuItem>
)}
</CustomMenu>
);
});

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { PiChatLogo } from "@plane/propel/icons";
import { DashboardsOutline, DraftsOutline, HomeOutline, YourWorkOutline } from "@makeplane/propel/icons";
import { EUserWorkspaceRoles } from "@plane/types";
// hooks
import { useUserPermissions, useUser } from "@/hooks/store/user";
// local imports
import { SidebarUserMenuItem } from "./user-menu-item";
export const SidebarUserMenu = observer(function SidebarUserMenu() {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { workspaceUserInfo } = useUserPermissions();
const { data: currentUser } = useUser();
const SIDEBAR_USER_MENU_ITEMS = [
{
key: "home",
labelTranslationKey: "sidebar.home",
href: `/${workspaceSlug.toString()}/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: HomeOutline,
},
{
key: "dashboards",
labelTranslationKey: "workspace_dashboards",
href: `/${workspaceSlug.toString()}/dashboards/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: DashboardsOutline,
},
{
key: "your-work",
labelTranslationKey: "sidebar.your_work",
href: `/${workspaceSlug.toString()}/profile/${currentUser?.id}/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: YourWorkOutline,
},
{
key: "drafts",
labelTranslationKey: "sidebar.drafts",
href: `/${workspaceSlug.toString()}/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: DraftsOutline,
},
{
key: "pi-chat",
labelTranslationKey: "sidebar.pi_chat",
href: `/${workspaceSlug.toString()}/pi-chat/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: PiChatLogo,
},
];
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;
return (
<div className="flex flex-col gap-0.5">
{SIDEBAR_USER_MENU_ITEMS.map((item) => (
<SidebarUserMenuItem key={item.key} item={item} draftIssueCount={draftIssueCount} />
))}
</div>
);
});

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, useRef } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { ArchiveOutline, ChevronRightOutline, MoreHorizontalOutline, SettingsOutline } from "@makeplane/propel/icons";
import { Disclosure } from "@headlessui/react";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { EUserWorkspaceRoles } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// store hooks
import { useUserPermissions } from "@/hooks/store/user";
export type SidebarWorkspaceMenuHeaderProps = {
isWorkspaceMenuOpen: boolean;
toggleWorkspaceMenu: (value: boolean) => void;
};
export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenuHeader(
props: SidebarWorkspaceMenuHeaderProps
) {
const { isWorkspaceMenuOpen, toggleWorkspaceMenu } = props;
// state
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// hooks
const { workspaceSlug } = useParams();
const router = useRouter();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
// TODO: fix types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
return (
<div className="group/workspace-button mt-2.5 flex rounded-sm bg-surface-1 px-2 hover:bg-surface-2">
<Disclosure.Button
as="button"
className="sticky top-0 z-10 flex w-full flex-1 items-center justify-between gap-1 py-1.5 text-13 font-semibold text-placeholder"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
<span>{t("common.workspace")}</span>
</Disclosure.Button>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="my-auto grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1"
onClick={() => {
setIsMenuActive(!isMenuActive);
}}
>
<MoreHorizontalOutline className="size-4" />
</span>
}
className={cn(
"pointer-events-none z-20 my-auto flex h-full flex-shrink-0 items-center opacity-0 group-hover/workspace-button:pointer-events-auto group-hover/workspace-button:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/projects/archives`)}>
<div className="flex items-center justify-start gap-2">
<ArchiveOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
{isAdmin && (
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings`)}>
<div className="flex items-center justify-start gap-2">
<SettingsOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
<Disclosure.Button
as="button"
className="group/workspace-button sticky top-0 z-10 flex items-center justify-between gap-1 rounded-sm px-0.5 py-1.5 text-11 font-semibold text-placeholder hover:bg-surface-2"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
{" "}
<span className="pointer-events-none flex-shrink-0 rounded-sm opacity-0 group-hover/workspace-button:pointer-events-auto group-hover/workspace-button:opacity-100 hover:bg-layer-1">
<ChevronRightOutline
className={cn("size-4 flex-shrink-0 text-placeholder transition-transform", {
"rotate-90": isWorkspaceMenuOpen,
})}
/>
</span>
</Disclosure.Button>
</div>
);
});

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { EUserWorkspaceRoles } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { UpgradeBadge } from "@/components/workspace/upgrade-badge";
export type SidebarWorkspaceMenuItemProps = {
item: {
labelTranslationKey: string;
key: string;
href: string;
Icon: any;
access: EUserWorkspaceRoles[];
};
};
export const SidebarWorkspaceMenuItem = observer(function SidebarWorkspaceMenuItem(
props: SidebarWorkspaceMenuItemProps
) {
const { item } = props;
const { t } = useTranslation();
// nextjs hooks
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { allowPermissions } = useUserPermissions();
// store hooks
const { toggleSidebar } = useAppTheme();
const handleLinkClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
};
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
const isActive = item.href === pathname;
return (
<Link href={item.href} onClick={() => handleLinkClick()}>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
<item.Icon
className={cn("size-4", {
"rotate-180": item.key === "active_cycles",
})}
/>
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
</SidebarNavItem>
</Link>
);
});

View File

@@ -0,0 +1,231 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useState, useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { ChevronDownOutline, LogOutOutline, MailOutline, PlusCircleOutline } from "@makeplane/propel/icons";
// ui
import { Menu, Transition } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspace } from "@plane/types";
import { Loader } from "@plane/ui";
import { orderWorkspacesList, cn } from "@plane/utils";
// helpers
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile } from "@/hooks/store/user";
import { useInstance } from "@/hooks/store/use-instance";
// components
import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation";
};
export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) {
const { variant } = props;
// store hooks
const { toggleSidebar, toggleAnySidebarDropdown } = useAppTheme();
const { config } = useInstance();
const { data: currentUser } = useUser();
const { signOut } = useUser();
const { updateUserProfile } = useUserProfile();
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
// derived values
const isWorkspaceCreationDisabled = config?.is_workspace_creation_disabled ?? false;
// translation
const { t } = useTranslation();
// local state
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false);
const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("auth.sign_out.toast.error.title"),
message: t("auth.sign_out.toast.error.message"),
})
);
};
const handleItemClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
};
const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
// TODO: fix workspaces list scroll
// Toggle sidebar dropdown state when either menu is open
useEffect(() => {
toggleAnySidebarDropdown(isWorkspaceMenuOpen);
}, [isWorkspaceMenuOpen, toggleAnySidebarDropdown]);
return (
<Menu
as="div"
className={cn("relative flex h-full w-fit max-w-48 truncate whitespace-nowrap", {
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch truncate text-left": variant === "top-navigation",
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
// Update local state directly
if (isWorkspaceMenuOpen !== open) {
setIsWorkspaceMenuOpen(open);
}
return (
<>
{variant === "sidebar" && (
<Menu.Button
className={cn("flex size-8 w-full items-center justify-center rounded-md", {
"bg-layer-1": open,
})}
>
<AppSidebarItem
variant="button"
item={{
icon: (
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-md border border-subtle"
/>
),
}}
/>
</Menu.Button>
)}
{variant === "top-navigation" && (
<Menu.Button
className={cn(
"group/menu-button flex flex-grow items-center justify-between gap-1 truncate rounded-sm p-1 text-13 font-medium text-secondary hover:bg-layer-1 focus:outline-none",
{
"bg-layer-1": open,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex flex-grow items-center gap-2 truncate">
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="border border-subtle rounded-md size-7"
/>
<h4 className="truncate text-14 font-medium text-primary">{activeWorkspace?.name ?? t("loading")}</h4>
</div>
<ChevronDownOutline
className={cn("size-4 flex-shrink-0 text-placeholder duration-300", {
"rotate-180": open,
})}
/>
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="trnsform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div
className={cn(
"fixed z-21 mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200 outline-none",
{
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
}
)}
>
<div className="vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-x-hidden overflow-y-scroll">
<span className="sticky top-0 z-21 h-full w-full flex-shrink-0 truncate rounded-md bg-surface-1 px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder">
{currentUser?.email}
</span>
{workspacesList ? (
<div className="flex size-full flex-col items-start justify-start">
{(activeWorkspace
? [
activeWorkspace,
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
]
: workspacesList
).map((workspace) => (
<SidebarDropdownItem
key={workspace.id}
workspace={workspace}
activeWorkspace={activeWorkspace}
handleItemClick={handleItemClick}
handleWorkspaceNavigation={handleWorkspaceNavigation}
handleClose={close}
/>
))}
</div>
) : (
<div className="w-full">
<Loader className="space-y-2">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</div>
)}
</div>
<div className="flex w-full flex-col items-start justify-start gap-2 px-4 py-2 text-13">
{!isWorkspaceCreationDisabled && (
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
>
<PlusCircleOutline className="size-4 flex-shrink-0" />
{t("create_workspace")}
</Menu.Item>
</Link>
)}
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
<Menu.Item
as="div"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
>
<MailOutline className="h-4 w-4 flex-shrink-0" />
{t("workspace_invites")}
</Menu.Item>
</Link>
<div className="w-full">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-danger-primary hover:bg-layer-transparent-hover"
onClick={handleSignOut}
>
<LogOutOutline className="size-4 flex-shrink-0" />
{t("sign_out")}
</Menu.Item>
</div>
</div>
</div>
</Menu.Items>
</Transition>
</>
);
}}
</Menu>
);
});

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { AnalyticsOutline, CyclesOutline, ProjectsOutline, ViewsOutline } from "@makeplane/propel/icons";
import { EUserWorkspaceRoles } from "@plane/types";
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
// local imports
import { SidebarWorkspaceMenuHeader } from "./workspace-menu-header";
import { SidebarWorkspaceMenuItem } from "./workspace-menu-item";
export const SidebarWorkspaceMenu = observer(function SidebarWorkspaceMenu() {
// router params
const { workspaceSlug } = useParams();
// local storage
const { setValue: toggleWorkspaceMenu, storedValue } = useLocalStorage<boolean>("is_workspace_menu_open", true);
// derived values
const isWorkspaceMenuOpen = !!storedValue;
const SIDEBAR_WORKSPACE_MENU_ITEMS = [
{
key: "projects",
labelTranslationKey: "sidebar.projects",
href: `/${workspaceSlug}/projects/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: ProjectsOutline,
},
{
key: "views",
labelTranslationKey: "sidebar.views",
href: `/${workspaceSlug}/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: ViewsOutline,
},
{
key: "active-cycles",
labelTranslationKey: "sidebar.cycles",
href: `/${workspaceSlug}/active-cycles/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: CyclesOutline,
},
{
key: "analytics",
labelTranslationKey: "sidebar.analytics",
href: `/${workspaceSlug}/analytics/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: AnalyticsOutline,
},
];
return (
<Disclosure as="div" defaultOpen>
<SidebarWorkspaceMenuHeader isWorkspaceMenuOpen={isWorkspaceMenuOpen} toggleWorkspaceMenu={toggleWorkspaceMenu} />
<Transition
as="div"
show={isWorkspaceMenuOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isWorkspaceMenuOpen && (
<Disclosure.Panel as="div" className="mt-0.5 flex flex-col gap-0.5" static>
{SIDEBAR_WORKSPACE_MENU_ITEMS.map((item) => (
<SidebarWorkspaceMenuItem key={item.key} item={item} />
))}
</Disclosure.Panel>
)}
</Transition>
</Disclosure>
);
});

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// helpers
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
type TUpgradeBadge = {
className?: string;
size?: "sm" | "md";
};
export function UpgradeBadge(props: TUpgradeBadge) {
const { className, size = "sm" } = props;
const { t } = useTranslation();
return (
<div
className={cn(
"w-fit cursor-pointer rounded-2xl bg-accent-primary/20 text-center font-medium text-accent-secondary outline-none",
{
"px-3 text-13": size === "md",
"px-2 text-11": size === "sm",
},
className
)}
>
{t("sidebar.pro")}
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslation } from "@plane/i18n";
// helpers
import { truncateText } from "@plane/utils";
type Props = { view: { key: string; i18n_label: string } };
export const GlobalDefaultViewListItem = observer(function GlobalDefaultViewListItem(props: Props) {
const { view } = props;
// router
const { workspaceSlug } = useParams();
const { t } = useTranslation();
return (
<div className="group border-b border-subtle hover:bg-surface-2">
<Link href={`/${workspaceSlug}/workspace-views/${view.key}`}>
<div className="relative flex h-[52px] w-full items-center justify-between rounded-sm px-5 py-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<p className="truncate text-13 leading-4 font-medium">{truncateText(t(view.i18n_label), 75)}</p>
</div>
</div>
</div>
</div>
</Link>
</div>
);
});

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane imports
import { LinkOutline, NewTabOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// ui
import type { TStaticViewTypes } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// helpers
type Props = {
workspaceSlug: string;
view: {
key: TStaticViewTypes;
i18n_label: string;
};
};
export const DefaultWorkspaceViewQuickActions = observer(function DefaultWorkspaceViewQuickActions(props: Props) {
const { workspaceSlug, view } = props;
const { t } = useTranslation();
const viewLink = `${workspaceSlug}/workspace-views/${view.key}`;
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: t("open_in_new_tab"),
icon: NewTabOutline,
},
{
key: "copy-link",
action: handleCopyText,
title: t("copy_link"),
icon: LinkOutline,
},
];
return (
<>
<CustomMenu
ellipsis
placement="bottom-end"
closeOnSelect
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-layer-1/70 rounded-sm"
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{t(item.title || "")}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// Plane Imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceView } from "@plane/types";
import { AlertModalCore } from "@plane/ui";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
type Props = {
data: IWorkspaceView;
isOpen: boolean;
onClose: () => void;
};
export const DeleteGlobalViewModal = observer(function DeleteGlobalViewModal(props: Props) {
const { data, isOpen, onClose } = props;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// router
const { workspaceSlug } = useParams();
// store hooks
const { deleteGlobalView } = useGlobalView();
const { t } = useTranslation();
const handleClose = () => onClose();
const handleDeletion = async () => {
if (!workspaceSlug) return;
setIsDeleteLoading(true);
try {
await deleteGlobalView(workspaceSlug.toString(), data.id);
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to delete the view. Please try again.",
});
}
setIsDeleteLoading(false);
handleClose();
// remove filters from local storage
localStorage.removeItem(`global_view_filters/${data.id}`);
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading}
isOpen={isOpen}
title={t("workspace_views.delete_view.title")}
content={<>{t("workspace_views.delete_view.content")}</>}
/>
);
});

View File

@@ -0,0 +1,213 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IWorkspaceView, IIssueFilters } from "@plane/types";
import { EViewAccess, EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@plane/utils";
// components
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
// plane web imports
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
type Props = {
handleFormSubmit: (values: Partial<IWorkspaceView>) => Promise<void>;
handleClose: () => void;
data?: IWorkspaceView;
preLoadedData?: Partial<IWorkspaceView>;
workspaceSlug: string;
};
const DEFAULT_VALUES: Partial<IWorkspaceView> = {
name: "",
description: "",
access: EViewAccess.PUBLIC,
display_properties: getComputedDisplayProperties(),
display_filters: getComputedDisplayFilters({
layout: EIssueLayoutTypes.SPREADSHEET,
order_by: "-created_at",
}),
};
export const WorkspaceViewForm = observer(function WorkspaceViewForm(props: Props) {
const { handleFormSubmit, handleClose, data, preLoadedData, workspaceSlug } = props;
// i18n
const { t } = useTranslation();
// form info
const defaultValues = {
...DEFAULT_VALUES,
...preLoadedData,
...data,
};
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
getValues,
} = useForm<IWorkspaceView>({
defaultValues,
});
// derived values
const workItemFilters: IIssueFilters = {
richFilters: getValues("rich_filters"),
displayFilters: getValues("display_filters"),
displayProperties: getValues("display_properties"),
kanbanFilters: undefined,
};
const handleCreateUpdateView = async (formData: Partial<IWorkspaceView>) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
return (
<form onSubmit={handleSubmit(handleCreateUpdateView)}>
<div className="space-y-5 p-5">
<h3 className="text-18 font-medium text-secondary">{data ? t("view.update.label") : t("view.create.label")}</h3>
<div className="space-y-3">
<div className="space-y-1">
<Controller
control={control}
name="name"
rules={{
required: t("form.title.required"),
maxLength: {
value: 255,
message: t("form.title.max_length", { length: 255 }),
},
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="name" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="name"
name="name"
type="name"
value={value}
onChange={onChange}
ref={ref}
placeholder={t("common.title")}
/>
</InputGroup>
</Field>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
<div>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<Field name="description" invalid={Boolean(errors?.description)}>
<TextAreaGroup resize="none">
<TextArea
size="lg"
surface="field"
autoResize
maxRows={8}
id="description"
name="description"
value={value}
placeholder={t("common.description")}
onChange={onChange}
/>
</TextAreaGroup>
</Field>
)}
/>
</div>
<div className="flex gap-2">
{/* display filters dropdown */}
<Controller
control={control}
name="display_filters"
render={({ field: { onChange: onDisplayFiltersChange, value: displayFilters } }) => (
<Controller
control={control}
name="display_properties"
render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => (
<FiltersDropdown title={t("common.display")}>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions.spreadsheet}
displayFilters={displayFilters ?? {}}
handleDisplayFiltersUpdate={(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
onDisplayFiltersChange({
...displayFilters,
...updatedDisplayFilter,
});
}}
displayProperties={displayProperties ?? {}}
handleDisplayPropertiesUpdate={(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
onDisplayPropertiesChange({
...displayProperties,
...updatedDisplayProperties,
});
}}
/>
</FiltersDropdown>
)}
/>
)}
/>
</div>
<div>
{/* filters dropdown */}
<Controller
control={control}
name="rich_filters"
render={({ field: { onChange: onFiltersChange } }) => (
<WorkspaceLevelWorkItemFiltersHOC
entityId={data?.id}
entityType={EIssuesStoreType.GLOBAL}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.filters}
initialWorkItemFilters={workItemFilters}
isTemporary
updateFilters={(updateFilters) => onFiltersChange(updateFilters)}
showOnMount
workspaceSlug={workspaceSlug}
>
{({ filter: workspaceViewWorkItemsFilter }) =>
workspaceViewWorkItemsFilter && (
<WorkItemFiltersRow filter={workspaceViewWorkItemsFilter} variant="modal" />
)
}
</WorkspaceLevelWorkItemFiltersHOC>
)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
<Button variant="secondary" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
{data
? isSubmitting
? t("common.updating")
: t("view.update.label")
: isSubmitting
? t("common.creating")
: t("view.create.label")}
</Button>
</div>
</form>
);
});

View File

@@ -0,0 +1,121 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { AddOutline } from "@makeplane/propel/icons";
import type { TStaticViewTypes } from "@plane/types";
import { Header, EHeaderVariant } from "@plane/ui";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { DefaultWorkspaceViewQuickActions } from "./default-view-quick-action";
import { CreateUpdateWorkspaceViewModal } from "./modal";
import { WorkspaceViewQuickActions } from "./quick-action";
const ViewTab = observer(function ViewTab(props: { viewId: string }) {
const { viewId } = props;
// refs
const parentRef = useRef<HTMLDivElement>(null);
// router
const { workspaceSlug, globalViewId } = useParams();
// store hooks
const { getViewDetailsById } = useGlobalView();
const view = getViewDetailsById(viewId);
if (!view || !workspaceSlug || !globalViewId) return null;
return (
<div ref={parentRef} className="relative">
<WorkspaceViewQuickActions workspaceSlug={workspaceSlug?.toString()} view={view} />
</div>
);
});
function DefaultViewTab(props: {
tab: {
key: TStaticViewTypes;
i18n_label: string;
};
}) {
const { tab } = props;
// refs
const parentRef = useRef<HTMLDivElement>(null);
// router
const { workspaceSlug, globalViewId } = useParams();
if (!workspaceSlug || !globalViewId) return null;
return (
<div key={tab.key} ref={parentRef} className="relative">
<DefaultWorkspaceViewQuickActions workspaceSlug={workspaceSlug?.toString()} view={tab} />
</div>
);
}
export const GlobalViewsHeader = observer(function GlobalViewsHeader() {
// states
const [createViewModal, setCreateViewModal] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// router
const { globalViewId } = useParams();
// store hooks
const { currentWorkspaceViews } = useGlobalView();
const { allowPermissions } = useUserPermissions();
// bring the active view to the centre of the header
useEffect(() => {
if (globalViewId && currentWorkspaceViews) {
const activeTabElement = document.querySelector(`#global-view-${globalViewId.toString()}`);
if (activeTabElement && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const activeTabRect = activeTabElement.getBoundingClientRect();
const diff = containerRect.right - activeTabRect.right;
activeTabElement.scrollIntoView({ behavior: "smooth", inline: diff > 500 ? "center" : "nearest" });
}
}
}, [globalViewId, currentWorkspaceViews, containerRef]);
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
return (
<Header variant={EHeaderVariant.SECONDARY} className="z-[12] min-h-[44px] bg-surface-1">
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
<div
ref={containerRef}
className="horizontal-scrollbar scrollbar-sm flex h-full w-full items-center overflow-x-auto overflow-y-hidden"
>
{DEFAULT_GLOBAL_VIEWS_LIST.map((tab, index) => (
<DefaultViewTab key={`${tab.key}-${index}`} tab={tab} />
))}
{currentWorkspaceViews?.map((viewId) => (
<ViewTab key={viewId} viewId={viewId} />
))}
</div>
{isAuthorizedUser ? (
<button
type="button"
className="sticky -right-4 flex flex-shrink-0 items-center justify-center border-transparent bg-surface-1 py-3 hover:border-subtle hover:text-placeholder"
onClick={() => setCreateViewModal(true)}
>
<AddOutline className="h-4 w-4 text-accent-secondary" />
</button>
) : (
<></>
)}
</Header>
);
});

View File

@@ -0,0 +1,117 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceView } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { WorkspaceViewForm } from "./form";
type Props = {
data?: IWorkspaceView;
isOpen: boolean;
onClose: () => void;
preLoadedData?: Partial<IWorkspaceView>;
};
export const CreateUpdateWorkspaceViewModal = observer(function CreateUpdateWorkspaceViewModal(props: Props) {
const { isOpen, onClose, data, preLoadedData } = props;
// router
const router = useAppRouter();
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
// store hooks
const { createGlobalView, updateGlobalView } = useGlobalView();
const { resetExpression } = useWorkItemFilters();
const handleClose = () => {
onClose();
};
const handleCreateView = async (payload: Partial<IWorkspaceView>) => {
if (!workspaceSlug) return;
try {
const payloadData: Partial<IWorkspaceView> = {
...payload,
rich_filters: {
...payload?.rich_filters,
},
};
const res = await createGlobalView(workspaceSlug, payloadData);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "View created successfully.",
});
router.push(`/${workspaceSlug}/workspace-views/${res.id}`);
handleClose();
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "View could not be created. Please try again.",
});
}
};
const handleUpdateView = async (payload: Partial<IWorkspaceView>) => {
if (!workspaceSlug || !data) return;
try {
const payloadData: Partial<IWorkspaceView> = {
...payload,
query: {
...payload?.rich_filters,
},
};
const res = await updateGlobalView(workspaceSlug, data.id, payloadData);
if (res) {
resetExpression(EIssuesStoreType.GLOBAL, data.id, res.rich_filters);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "View updated successfully.",
});
handleClose();
}
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "View could not be updated. Please try again.",
});
}
};
const handleFormSubmit = async (formData: Partial<IWorkspaceView>) => {
if (!workspaceSlug) return;
if (!data) await handleCreateView(formData);
else await handleUpdateView(formData);
};
if (!workspaceSlug) return null;
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<WorkspaceViewForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
data={data}
preLoadedData={preLoadedData}
workspaceSlug={workspaceSlug}
/>
</ModalCore>
);
});

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceView } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// helpers
import { useViewMenuItems } from "@/components/common/quick-actions-helper";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store/user";
// local imports
import { DeleteGlobalViewModal } from "./delete-view-modal";
import { CreateUpdateWorkspaceViewModal } from "./modal";
type Props = {
workspaceSlug: string;
view: IWorkspaceView;
};
export const WorkspaceViewQuickActions = observer(function WorkspaceViewQuickActions(props: Props) {
const { workspaceSlug, view } = props;
// states
const [updateViewModal, setUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// store hooks
const { data } = useUser();
const { allowPermissions } = useUserPermissions();
// auth
const isOwner = view?.owned_by === data?.id;
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const viewLink = `${workspaceSlug}/workspace-views/${view.id}`;
const handleCopyText = async () => {
await copyUrlToClipboard(viewLink);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
};
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
const MENU_ITEMS = useViewMenuItems({
isOwner,
isAdmin,
handleDelete: () => setDeleteViewModal(true),
handleEdit: () => setUpdateViewModal(true),
handleOpenInNewTab,
handleCopyLink: handleCopyText,
workspaceSlug,
view,
});
return (
<>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
<DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<CustomMenu
ellipsis
placement="bottom-end"
closeOnSelect
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-layer-1/70 rounded-sm"
>
{MENU_ITEMS.items.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// plane imports
import { DeleteOutline, EditOutline } from "@makeplane/propel/icons";
import { CustomMenu } from "@plane/ui";
import { truncateText } from "@plane/utils";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
// local imports
import { DeleteGlobalViewModal } from "./delete-view-modal";
import { CreateUpdateWorkspaceViewModal } from "./modal";
type Props = { viewId: string };
export const GlobalViewListItem = observer(function GlobalViewListItem(props: Props) {
const { viewId } = props;
// states
const [updateViewModal, setUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// router
const { workspaceSlug } = useParams();
// store hooks
const { getViewDetailsById } = useGlobalView();
// derived data
const view = getViewDetailsById(viewId);
if (!view) return null;
return (
<>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
<DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<div className="group border-b border-subtle hover:bg-surface-2">
<Link href={`/${workspaceSlug}/workspace-views/${view.id}`}>
<div className="relative flex h-[52px] w-full items-center justify-between rounded-sm p-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<p className="truncate text-13 leading-4 font-medium">{truncateText(view.name, 75)}</p>
{view?.description && <p className="text-11 text-secondary">{view.description}</p>}
</div>
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-4">
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
setUpdateViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<EditOutline width={14} height={14} />
<span>Edit View</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setDeleteViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<DeleteOutline width={14} height={14} />
<span>Delete View</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</Link>
</div>
</>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { ViewListLoader } from "@/components/ui/loader/view-list-loader";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
// local imports
import { GlobalViewListItem } from "./view-list-item";
type Props = {
searchQuery: string;
};
export const GlobalViewsList = observer(function GlobalViewsList(props: Props) {
const { searchQuery } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
const { fetchAllGlobalViews, currentWorkspaceViews, getSearchedViews } = useGlobalView();
useSWR(
workspaceSlug ? `GLOBAL_VIEWS_LIST_${workspaceSlug.toString()}` : null,
workspaceSlug ? () => fetchAllGlobalViews(workspaceSlug.toString()) : null
);
if (!currentWorkspaceViews) return <ViewListLoader />;
const filteredViewsList = getSearchedViews(searchQuery);
return (
<>
{filteredViewsList?.map((viewId) => (
<GlobalViewListItem key={viewId} viewId={viewId} />
))}
</>
);
});