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
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:
@@ -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>
|
||||
);
|
||||
});
|
||||
141
apps/web/core/components/workspace/billing/comparison/base.tsx
Normal file
141
apps/web/core/components/workspace/billing/comparison/base.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
1311
apps/web/core/components/workspace/billing/comparison/plans.tsx
Normal file
1311
apps/web/core/components/workspace/billing/comparison/plans.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/workspace/billing/index.ts
Normal file
7
apps/web/core/components/workspace/billing/index.ts
Normal 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";
|
||||
71
apps/web/core/components/workspace/billing/root.tsx
Normal file
71
apps/web/core/components/workspace/billing/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
42
apps/web/core/components/workspace/content-wrapper.tsx
Normal file
42
apps/web/core/components/workspace/content-wrapper.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
261
apps/web/core/components/workspace/create-workspace-form.tsx
Normal file
261
apps/web/core/components/workspace/create-workspace-form.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
171
apps/web/core/components/workspace/delete-workspace-form.tsx
Normal file
171
apps/web/core/components/workspace/delete-workspace-form.tsx
Normal 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'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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
46
apps/web/core/components/workspace/edition-badge.tsx
Normal file
46
apps/web/core/components/workspace/edition-badge.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
71
apps/web/core/components/workspace/invite-modal/actions.tsx
Normal file
71
apps/web/core/components/workspace/invite-modal/actions.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
132
apps/web/core/components/workspace/invite-modal/fields.tsx
Normal file
132
apps/web/core/components/workspace/invite-modal/fields.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
34
apps/web/core/components/workspace/invite-modal/form.tsx
Normal file
34
apps/web/core/components/workspace/invite-modal/form.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
41
apps/web/core/components/workspace/logo.tsx
Normal file
41
apps/web/core/components/workspace/logo.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/workspace/members/index.ts
Normal file
7
apps/web/core/components/workspace/members/index.ts
Normal 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";
|
||||
66
apps/web/core/components/workspace/members/invite-modal.tsx
Normal file
66
apps/web/core/components/workspace/members/invite-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
192
apps/web/core/components/workspace/settings/member-columns.tsx
Normal file
192
apps/web/core/components/workspace/settings/member-columns.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
101
apps/web/core/components/workspace/settings/members-list.tsx
Normal file
101
apps/web/core/components/workspace/settings/members-list.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
137
apps/web/core/components/workspace/settings/useMemberColumns.tsx
Normal file
137
apps/web/core/components/workspace/settings/useMemberColumns.tsx
Normal 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 };
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
125
apps/web/core/components/workspace/sidebar/dropdown-item.tsx
Normal file
125
apps/web/core/components/workspace/sidebar/dropdown-item.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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"} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
44
apps/web/core/components/workspace/sidebar/helper.tsx
Normal file
44
apps/web/core/components/workspace/sidebar/helper.tsx
Normal 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)} />;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
290
apps/web/core/components/workspace/sidebar/projects-list.tsx
Normal file
290
apps/web/core/components/workspace/sidebar/projects-list.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
96
apps/web/core/components/workspace/sidebar/quick-actions.tsx
Normal file
96
apps/web/core/components/workspace/sidebar/quick-actions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
81
apps/web/core/components/workspace/sidebar/sidebar-item.tsx
Normal file
81
apps/web/core/components/workspace/sidebar/sidebar-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
150
apps/web/core/components/workspace/sidebar/user-menu-root.tsx
Normal file
150
apps/web/core/components/workspace/sidebar/user-menu-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
73
apps/web/core/components/workspace/sidebar/user-menu.tsx
Normal file
73
apps/web/core/components/workspace/sidebar/user-menu.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
35
apps/web/core/components/workspace/upgrade-badge.tsx
Normal file
35
apps/web/core/components/workspace/upgrade-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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")}</>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
213
apps/web/core/components/workspace/views/form.tsx
Normal file
213
apps/web/core/components/workspace/views/form.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
121
apps/web/core/components/workspace/views/header.tsx
Normal file
121
apps/web/core/components/workspace/views/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
117
apps/web/core/components/workspace/views/modal.tsx
Normal file
117
apps/web/core/components/workspace/views/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
109
apps/web/core/components/workspace/views/quick-action.tsx
Normal file
109
apps/web/core/components/workspace/views/quick-action.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
83
apps/web/core/components/workspace/views/view-list-item.tsx
Normal file
83
apps/web/core/components/workspace/views/view-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
44
apps/web/core/components/workspace/views/views-list.tsx
Normal file
44
apps/web/core/components/workspace/views/views-list.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user