Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e7dcda41 | |||
| 5017534e5c | |||
| 6e5031196b | |||
| bcfefea323 | |||
| 3927fbd0c7 | |||
| 74320c1062 | |||
| 2acba2980b |
@@ -11,7 +11,7 @@ import { useRouter } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { Tabs } from "@plane/propel/tabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
|
||||
@@ -80,9 +80,9 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
"flex w-full items-center justify-between gap-4 overflow-hidden border-b border-subtle bg-surface-1 px-6 py-2"
|
||||
)}
|
||||
>
|
||||
<Tabs.List className={"flex h-7 w-fit overflow-x-auto"}>
|
||||
<TabsList className={"flex h-7 w-fit overflow-x-auto"}>
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tabs.Trigger
|
||||
<TabsTrigger
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
disabled={tab.isDisabled}
|
||||
@@ -95,22 +95,22 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<AnalyticsFilterActions />
|
||||
</div>
|
||||
</div>
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tabs.Content
|
||||
<TabsContent
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={"h-full overflow-hidden overflow-y-auto px-2"}
|
||||
>
|
||||
<tab.content />
|
||||
</Tabs.Content>
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import type { ICycle, IModule, IProject } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
@@ -75,13 +74,11 @@ export const WorkItemsModalMainContent = observer(function WorkItemsModalMainCon
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Group as={React.Fragment}>
|
||||
<div className="flex flex-col gap-14 overflow-y-auto p-6">
|
||||
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</Tab.Group>
|
||||
<div className="flex flex-col gap-14 overflow-y-auto p-6">
|
||||
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,11 +11,9 @@ import { useDropzone } from "react-dropzone";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import useSWR from "swr";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { Tabs } from "@plane/propel/tabs";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
@@ -27,6 +25,8 @@ import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
import { Popover } from "@plane/propel/popover";
|
||||
|
||||
type TTabOption = {
|
||||
key: string;
|
||||
@@ -188,31 +188,27 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
|
||||
useOutsideClickDetector(ref, handleClose);
|
||||
|
||||
return (
|
||||
<Popover className="relative z-19" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Popover.Button className={getButtonStyling("secondary", "sm")} onClick={handleOnClick} disabled={disabled}>
|
||||
{label}
|
||||
</Popover.Button>
|
||||
|
||||
{isOpen && (
|
||||
<Popover.Panel
|
||||
className="absolute right-0 z-20 mt-2 rounded-md border border-subtle bg-surface-1 shadow-raised-200"
|
||||
static
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-2 rounded-md border border-subtle bg-surface-1 shadow-raised-200">
|
||||
<div
|
||||
ref={imagePickerRef}
|
||||
className="flex h-96 w-80 flex-col overflow-auto rounded border border-subtle bg-surface-1 shadow-raised-200 md:h-[36rem] md:w-[36rem]"
|
||||
className="flex h-96 w-80 flex-col overflow-auto rounded border border-subtle bg-surface-1 p-2 shadow-raised-200 md:h-[36rem] md:w-[36rem]"
|
||||
>
|
||||
<Tabs defaultValue={enabledTabs[0]?.key || "images"} className="flex h-full flex-col p-3">
|
||||
<Tabs.List className="flex rounded bg-layer-3 p-1">
|
||||
{enabledTabs.map((tab) => (
|
||||
<Tabs.Trigger key={tab.key} value={tab.key} size="md">
|
||||
<Tabs>
|
||||
<TabsList>
|
||||
{tabOptions.map((tab) => (
|
||||
<TabsTrigger key={tab.key} value={tab.key}>
|
||||
{tab.title}
|
||||
</Tabs.Trigger>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.List>
|
||||
<div className="vertical-scrollbar mt-3 scrollbar-sm flex-1 overflow-x-hidden overflow-y-auto p-3">
|
||||
<Tabs.Content value="unsplash" className="h-full w-full space-y-4">
|
||||
</TabsList>
|
||||
<div className="mt-1 flex-1 overflow-auto">
|
||||
<TabsContent value="unsplash">
|
||||
{(unsplashImages || !unsplashError) && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -234,11 +230,11 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
ref={ref}
|
||||
placeholder="Search for images"
|
||||
className="w-full text-13"
|
||||
className="text-sm w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="primary" size="xl" onClick={() => setSearchParams(formData.search)}>
|
||||
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="xl">
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
@@ -257,13 +253,13 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
|
||||
<img
|
||||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded-sm object-cover"
|
||||
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-7 text-center text-11 text-secondary">No images found.</p>
|
||||
<p className="text-xs text-custom-text-300 pt-7 text-center">No images found.</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-4 gap-4">
|
||||
@@ -279,8 +275,8 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="images" className="h-full w-full space-y-4">
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => (
|
||||
<div
|
||||
@@ -296,8 +292,8 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="upload" className="h-full w-full">
|
||||
</TabsContent>
|
||||
<TabsContent value="upload">
|
||||
<div className="flex h-full w-full flex-col gap-y-2">
|
||||
<div className="flex w-full flex-1 items-center gap-3">
|
||||
<div
|
||||
@@ -364,7 +360,7 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,15 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Fragment, useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import type { ICycle } from "@plane/types";
|
||||
@@ -70,18 +68,6 @@ export const ActiveCycleStats = observer(function ActiveCycleStats(props: Active
|
||||
const assigneesResolvedPath = resolvedTheme === "light" ? lightAssigneeAsset : darkAssigneeAsset;
|
||||
const labelsResolvedPath = resolvedTheme === "light" ? lightLabelAsset : darkLabelAsset;
|
||||
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Priority-Issues":
|
||||
return 0;
|
||||
case "Assignees":
|
||||
return 1;
|
||||
case "Labels":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const {
|
||||
issues: { fetchNextActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
@@ -107,269 +93,207 @@ export const ActiveCycleStats = observer(function ActiveCycleStats(props: Active
|
||||
|
||||
return cycleId ? (
|
||||
<div className="col-span-1 flex min-h-[17rem] flex-col gap-4 overflow-hidden rounded-lg border border-subtle bg-surface-1 p-4 lg:col-span-2 xl:col-span-1">
|
||||
<Tab.Group
|
||||
as={Fragment}
|
||||
defaultIndex={currentValue(tab)}
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setTab("Priority-Issues");
|
||||
case 1:
|
||||
return setTab("Assignees");
|
||||
case 2:
|
||||
return setTab("Labels");
|
||||
<Tabs value={tab ?? "Priority-Issues"} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="Priority-Issues">{t("project_cycles.active_cycle.priority_issue")}</TabsTrigger>
|
||||
<TabsTrigger value="Assignees">{t("project_cycles.active_cycle.assignees")}</TabsTrigger>
|
||||
<TabsTrigger value="Labels">{t("project_cycles.active_cycle.labels")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
default:
|
||||
return setTab("Priority-Issues");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="relative grid rounded-sm border-[0.5px] border-subtle bg-layer-1 p-[1px]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(3, 1fr)`,
|
||||
}}
|
||||
<TabsContent
|
||||
value="Priority-Issues"
|
||||
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
|
||||
>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
|
||||
{
|
||||
"bg-surface-1 text-tertiary": selected,
|
||||
"hover:text-tertiary": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
<div
|
||||
ref={issuesContainerRef}
|
||||
className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-1 overflow-y-auto"
|
||||
>
|
||||
{t("project_cycles.active_cycle.priority_issue")}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
|
||||
{
|
||||
"bg-surface-1 text-tertiary": selected,
|
||||
"hover:text-tertiary": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("project_cycles.active_cycle.assignees")}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
|
||||
{
|
||||
"bg-surface-1 text-tertiary": selected,
|
||||
"hover:text-tertiary": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("project_cycles.active_cycle.labels")}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
{cycleIssueDetails && "issueIds" in cycleIssueDetails ? (
|
||||
cycleIssueDetails.issueCount > 0 ? (
|
||||
<>
|
||||
{cycleIssueDetails.issueIds.map((issueId: string) => {
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
|
||||
>
|
||||
<div
|
||||
ref={issuesContainerRef}
|
||||
className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-1 overflow-y-auto"
|
||||
>
|
||||
{cycleIssueDetails && "issueIds" in cycleIssueDetails ? (
|
||||
cycleIssueDetails.issueCount > 0 ? (
|
||||
<>
|
||||
{cycleIssueDetails.issueIds.map((issueId: string) => {
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return null;
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md p-1 hover:bg-surface-2"
|
||||
onClick={() => {
|
||||
if (issue.id) {
|
||||
setPeekIssue({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId: issue.id,
|
||||
isArchived: !!issue.archived_at,
|
||||
});
|
||||
handleFiltersUpdate([
|
||||
{ property: "priority", operator: "in", value: ["urgent", "high"] },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full min-w-24 flex-grow items-center gap-1.5 truncate">
|
||||
<IssueIdentifier issueId={issue.id} projectId={projectId} size="xs" variant="secondary" />
|
||||
<Tooltip position="top-start" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="truncate text-13 text-primary">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled
|
||||
buttonVariant="background-with-text"
|
||||
buttonContainerClassName="cursor-pointer max-w-24"
|
||||
showTooltip
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip
|
||||
tooltipHeading="Target Date"
|
||||
tooltipContent={renderFormattedDate(issue.target_date)}
|
||||
>
|
||||
<div className="flex h-full cursor-pointer items-center gap-1.5 truncate rounded-sm bg-layer-1 px-2 py-0.5 text-11 group-hover:bg-surface-1">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate text-11">
|
||||
{renderFormattedDateWithoutYear(issue.target_date)}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
|
||||
<div
|
||||
ref={setIssueLoaderElement}
|
||||
className={
|
||||
"relative flex h-11 animate-pulse cursor-pointer items-center gap-3 bg-layer-1 p-3 text-13"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<SimpleEmptyState
|
||||
title={t("active_cycle.empty_state.priority_issue.title")}
|
||||
assetPath={priorityResolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
loaders
|
||||
)}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
|
||||
>
|
||||
{cycle && !isEmpty(cycle.distribution) ? (
|
||||
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
|
||||
cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={assignee?.display_name ?? undefined}
|
||||
src={getFileURL(assignee?.avatar_url ?? "")}
|
||||
/>
|
||||
|
||||
<span>{assignee.display_name}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
<div
|
||||
key={issue.id}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md p-1 hover:bg-surface-2"
|
||||
onClick={() => {
|
||||
if (assignee.assignee_id) {
|
||||
handleFiltersUpdate([
|
||||
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
|
||||
]);
|
||||
if (issue.id) {
|
||||
setPeekIssue({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId: issue.id,
|
||||
isArchived: !!issue.archived_at,
|
||||
});
|
||||
handleFiltersUpdate([{ property: "priority", operator: "in", value: ["urgent", "high"] }]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div className="flex w-full min-w-24 flex-grow items-center gap-1.5 truncate">
|
||||
<IssueIdentifier issueId={issue.id} projectId={projectId} size="xs" variant="secondary" />
|
||||
<Tooltip position="top-start" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="truncate text-13 text-primary">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled
|
||||
buttonVariant="background-with-text"
|
||||
buttonContainerClassName="cursor-pointer max-w-24"
|
||||
showTooltip
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip
|
||||
tooltipHeading="Target Date"
|
||||
tooltipContent={renderFormattedDate(issue.target_date)}
|
||||
>
|
||||
<div className="flex h-full cursor-pointer items-center gap-1.5 truncate rounded-sm bg-layer-1 px-2 py-0.5 text-11 group-hover:bg-surface-1">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate text-11">
|
||||
{renderFormattedDateWithoutYear(issue.target_date)}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-subtle bg-layer-1">
|
||||
<img src={userImage} height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>{t("no_assignee")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})
|
||||
})}
|
||||
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
|
||||
<div
|
||||
ref={setIssueLoaderElement}
|
||||
className={
|
||||
"relative flex h-11 animate-pulse cursor-pointer items-center gap-3 bg-layer-1 p-3 text-13"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<SimpleEmptyState
|
||||
title={t("active_cycle.empty_state.assignee.title")}
|
||||
assetPath={assigneesResolvedPath}
|
||||
title={t("active_cycle.empty_state.priority_issue.title")}
|
||||
assetPath={priorityResolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
loaders
|
||||
)}
|
||||
</Tab.Panel>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
|
||||
>
|
||||
{cycle && !isEmpty(cycle.distribution) ? (
|
||||
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
|
||||
cycle.distribution.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span
|
||||
className="block h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-11 text-ellipsis">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
onClick={
|
||||
label.label_id
|
||||
? () => {
|
||||
if (label.label_id) {
|
||||
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<SimpleEmptyState title={t("active_cycle.empty_state.label.title")} assetPath={labelsResolvedPath} />
|
||||
</div>
|
||||
)
|
||||
<TabsContent
|
||||
value="Assignees"
|
||||
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
|
||||
>
|
||||
{cycle && !isEmpty(cycle.distribution) ? (
|
||||
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
|
||||
cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={assignee?.display_name ?? undefined}
|
||||
src={getFileURL(assignee?.avatar_url ?? "")}
|
||||
/>
|
||||
|
||||
<span>{assignee.display_name}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
onClick={() => {
|
||||
if (assignee.assignee_id) {
|
||||
handleFiltersUpdate([
|
||||
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-subtle bg-layer-1">
|
||||
<img src={userImage} height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>{t("no_assignee")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
loaders
|
||||
)}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<SimpleEmptyState
|
||||
title={t("active_cycle.empty_state.assignee.title")}
|
||||
assetPath={assigneesResolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
loaders
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="Labels"
|
||||
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
|
||||
>
|
||||
{cycle && !isEmpty(cycle.distribution) ? (
|
||||
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
|
||||
cycle.distribution.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span
|
||||
className="block h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-11 text-ellipsis">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
onClick={
|
||||
label.label_id
|
||||
? () => {
|
||||
if (label.label_id) {
|
||||
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<SimpleEmptyState title={t("active_cycle.empty_state.label.title")} assetPath={labelsResolvedPath} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
loaders
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="col-span-1 flex min-h-[17rem] flex-col gap-4 overflow-hidden bg-surface-1 lg:col-span-2 xl:col-span-1">
|
||||
|
||||
@@ -147,9 +147,7 @@ export const CycleAnalyticsProgress = observer(function CycleAnalyticsProgress(p
|
||||
cycleId
|
||||
)}
|
||||
isEditable={Boolean(!peekCycle) && cycleFilter !== undefined}
|
||||
noBackground={false}
|
||||
plotType={plotType}
|
||||
roundedTab={false}
|
||||
selectedFilters={{
|
||||
assignees: selectedAssignees,
|
||||
labels: selectedLabels,
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
import type { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import type { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types";
|
||||
import { cn, toFilterArray } from "@plane/utils";
|
||||
import { toFilterArray } from "@plane/utils";
|
||||
// components
|
||||
import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
@@ -30,9 +30,7 @@ type TCycleProgressStats = {
|
||||
groupedIssues: Record<string, number>;
|
||||
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
|
||||
isEditable?: boolean;
|
||||
noBackground?: boolean;
|
||||
plotType: TCyclePlotType;
|
||||
roundedTab?: boolean;
|
||||
selectedFilters: TSelectedFilterProgressStats;
|
||||
size?: "xs" | "sm";
|
||||
totalIssuesCount: number;
|
||||
@@ -45,9 +43,7 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
|
||||
groupedIssues,
|
||||
handleFiltersUpdate,
|
||||
isEditable = false,
|
||||
noBackground = false,
|
||||
plotType,
|
||||
roundedTab = false,
|
||||
selectedFilters,
|
||||
size = "sm",
|
||||
totalIssuesCount,
|
||||
@@ -59,8 +55,6 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
|
||||
`cycle-analytics-tab-${cycleId}`,
|
||||
"stat-assignees"
|
||||
);
|
||||
// derived values
|
||||
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
|
||||
const currentDistribution = distribution as TCycleDistribution;
|
||||
const currentEstimateDistribution = distribution as TCycleEstimateDistribution;
|
||||
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
|
||||
@@ -121,34 +115,16 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className={cn(
|
||||
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
|
||||
roundedTab ? `rounded-3xl` : `rounded-md`,
|
||||
noBackground ? `` : `bg-layer-2`,
|
||||
size === "xs" ? `text-11` : `text-13`
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue={currentTab ?? "stat-assignees"} onValueChange={(value) => setCycleTab(value)}>
|
||||
<TabsList>
|
||||
{PROGRESS_STATS.map((stat) => (
|
||||
<Tab
|
||||
className={cn(
|
||||
`w-full cursor-pointer p-1 text-primary transition-all outline-none focus:outline-none`,
|
||||
roundedTab ? `rounded-3xl border border-subtle` : `rounded-sm`,
|
||||
stat.key === currentTab
|
||||
? "bg-layer-transparent-active text-secondary"
|
||||
: "text-placeholder hover:text-secondary"
|
||||
)}
|
||||
key={stat.key}
|
||||
onClick={() => setCycleTab(stat.key)}
|
||||
>
|
||||
<TabsTrigger key={stat.key} value={stat.key}>
|
||||
{t(stat.i18n_title)}
|
||||
</Tab>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="py-3 text-secondary">
|
||||
<Tab.Panel key={"stat-states"}>
|
||||
</TabsList>
|
||||
<div className="py-3">
|
||||
<TabsContent value="stat-states">
|
||||
<StateGroupStatComponent
|
||||
distribution={distributionStateData}
|
||||
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
|
||||
@@ -156,25 +132,25 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
|
||||
selectedStateGroups={selectedStateGroups}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-assignees"}>
|
||||
</TabsContent>
|
||||
<TabsContent value="stat-assignees">
|
||||
<AssigneeStatComponent
|
||||
distribution={distributionAssigneeData}
|
||||
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedAssigneeIds={selectedAssigneeIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-labels"}>
|
||||
</TabsContent>
|
||||
<TabsContent value="stat-labels">
|
||||
<LabelStatComponent
|
||||
distribution={distributionLabelData}
|
||||
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
// helpers
|
||||
import type { EProductSubscriptionEnum, TBillingFrequency, TSubscriptionPrice } from "@plane/types";
|
||||
import { cn, getBaseSubscriptionName, getSubscriptionName } from "@plane/utils";
|
||||
@@ -41,31 +41,20 @@ export const BasePaidPlanCard = observer(function BasePaidPlanCard(props: TBaseP
|
||||
const planeName = getSubscriptionName(planVariant);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-subtle bg-layer-2 px-3 py-6">
|
||||
<Tab.Group selectedIndex={selectedPlan === "month" ? 0 : 1}>
|
||||
<div className="flex h-9 w-full justify-center">
|
||||
<Tab.List className="flex w-60 space-x-1 rounded-md bg-layer-3 p-0.5">
|
||||
<div className="flex flex-col rounded-xl bg-layer-1 px-3 py-6">
|
||||
<Tabs value={selectedPlan} onValueChange={(value) => setSelectedPlan(value as TBillingFrequency)}>
|
||||
<div className="flex w-full justify-center">
|
||||
<TabsList>
|
||||
{prices.map((price: TSubscriptionPrice) => (
|
||||
<Tab
|
||||
key={price.key}
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"w-full rounded-sm py-1 text-caption-md-medium leading-5",
|
||||
selected
|
||||
? "border border-subtle-1 bg-layer-2 text-primary shadow-raised-100"
|
||||
: "text-tertiary hover:text-secondary"
|
||||
)
|
||||
}
|
||||
onClick={() => setSelectedPlan(price.recurring)}
|
||||
>
|
||||
<TabsTrigger key={price.key} value={price.recurring}>
|
||||
{renderPriceContent(price)}
|
||||
</Tab>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</Tab.List>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Tab.Panels>
|
||||
<div>
|
||||
{prices.map((price: TSubscriptionPrice) => (
|
||||
<Tab.Panel key={price.key}>
|
||||
<TabsContent key={price.key} value={price.recurring}>
|
||||
<div className="pt-6 text-center">
|
||||
<div className="text-h4-medium">Plane {planeName}</div>
|
||||
{renderActionButton(price)}
|
||||
@@ -89,10 +78,10 @@ export const BasePaidPlanCard = observer(function BasePaidPlanCard(props: TBaseP
|
||||
</ul>
|
||||
{extraFeatures && <div>{extraFeatures}</div>}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -214,15 +214,12 @@ export const ModuleAnalyticsProgress = observer(function ModuleAnalyticsProgress
|
||||
)}
|
||||
isEditable={Boolean(!peekModule) && moduleFilter !== undefined}
|
||||
moduleId={moduleId}
|
||||
noBackground={false}
|
||||
plotType={plotType}
|
||||
roundedTab={false}
|
||||
selectedFilters={{
|
||||
assignees: selectedAssignees,
|
||||
labels: selectedLabels,
|
||||
stateGroups: selectedStateGroups,
|
||||
}}
|
||||
size="xs"
|
||||
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
import type { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import type { TModuleDistribution, TModuleEstimateDistribution, TModulePlotType } from "@plane/types";
|
||||
import { cn, toFilterArray } from "@plane/utils";
|
||||
import { toFilterArray } from "@plane/utils";
|
||||
// components
|
||||
import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
@@ -28,11 +28,8 @@ type TModuleProgressStats = {
|
||||
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
|
||||
isEditable?: boolean;
|
||||
moduleId: string;
|
||||
noBackground?: boolean;
|
||||
plotType: TModulePlotType;
|
||||
roundedTab?: boolean;
|
||||
selectedFilters: TSelectedFilterProgressStats;
|
||||
size?: "xs" | "sm";
|
||||
totalIssuesCount: number;
|
||||
};
|
||||
|
||||
@@ -43,11 +40,8 @@ export const ModuleProgressStats = observer(function ModuleProgressStats(props:
|
||||
handleFiltersUpdate,
|
||||
isEditable = false,
|
||||
moduleId,
|
||||
noBackground = false,
|
||||
plotType,
|
||||
roundedTab = false,
|
||||
selectedFilters,
|
||||
size = "sm",
|
||||
totalIssuesCount,
|
||||
} = props;
|
||||
// plane imports
|
||||
@@ -57,8 +51,6 @@ export const ModuleProgressStats = observer(function ModuleProgressStats(props:
|
||||
`module-analytics-tab-${moduleId}`,
|
||||
"stat-assignees"
|
||||
);
|
||||
// derived values
|
||||
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
|
||||
const currentDistribution = distribution as TModuleDistribution;
|
||||
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
|
||||
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
|
||||
@@ -119,60 +111,40 @@ export const ModuleProgressStats = observer(function ModuleProgressStats(props:
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className={cn(
|
||||
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
|
||||
roundedTab ? `rounded-3xl` : `rounded-md`,
|
||||
noBackground ? `` : `bg-layer-2`,
|
||||
size === "xs" ? `text-11` : `text-13`
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue={currentTab ?? "stat-assignees"} onValueChange={(value) => setModuleTab(value)}>
|
||||
<TabsList>
|
||||
{PROGRESS_STATS.map((stat) => (
|
||||
<Tab
|
||||
className={cn(
|
||||
`w-full cursor-pointer p-1 text-primary transition-all outline-none focus:outline-none`,
|
||||
roundedTab ? `rounded-3xl border border-subtle` : `rounded-sm`,
|
||||
stat.key === currentTab
|
||||
? "bg-layer-transparent-active text-secondary"
|
||||
: "text-placeholder hover:text-secondary"
|
||||
)}
|
||||
key={stat.key}
|
||||
onClick={() => setModuleTab(stat.key)}
|
||||
>
|
||||
<TabsTrigger key={stat.key} value={stat.key}>
|
||||
{t(stat.i18n_title)}
|
||||
</Tab>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="py-3 text-secondary">
|
||||
<Tab.Panel key={"stat-assignees"}>
|
||||
<AssigneeStatComponent
|
||||
distribution={distributionAssigneeData}
|
||||
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedAssigneeIds={selectedAssigneeIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-labels"}>
|
||||
<LabelStatComponent
|
||||
distribution={distributionLabelData}
|
||||
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-states"}>
|
||||
<StateGroupStatComponent
|
||||
distribution={distributionStateData}
|
||||
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedStateGroups={selectedStateGroups}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</TabsList>
|
||||
<TabsContent value="stat-assignees">
|
||||
<AssigneeStatComponent
|
||||
distribution={distributionAssigneeData}
|
||||
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedAssigneeIds={selectedAssigneeIds}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="stat-labels">
|
||||
<LabelStatComponent
|
||||
distribution={distributionLabelData}
|
||||
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="stat-states">
|
||||
<StateGroupStatComponent
|
||||
distribution={distributionStateData}
|
||||
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedStateGroups={selectedStateGroups}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { ArrowRightCircle } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowRightCircle } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { useCallback } from "react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tabs } from "@plane/propel/tabs";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// hooks
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
@@ -26,7 +26,6 @@ import { PageNavigationPaneTabsList } from "./tabs-list";
|
||||
import type { INavigationPaneExtension } from "./types/extensions";
|
||||
|
||||
import {
|
||||
PAGE_NAVIGATION_PANE_TAB_KEYS,
|
||||
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
|
||||
PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM,
|
||||
PAGE_NAVIGATION_PANE_WIDTH,
|
||||
@@ -55,7 +54,6 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
|
||||
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM
|
||||
) as TPageNavigationPaneTab | null;
|
||||
const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline";
|
||||
const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab);
|
||||
|
||||
// Check if any extension is currently active based on query parameters
|
||||
const ActiveExtension = extensions.find((extension) => {
|
||||
@@ -75,8 +73,8 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(index: number) => {
|
||||
const updatedTab = PAGE_NAVIGATION_PANE_TAB_KEYS[index];
|
||||
(value: string) => {
|
||||
const updatedTab = value as TPageNavigationPaneTab;
|
||||
const isUpdatedTabInfo = updatedTab === "info";
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: updatedTab },
|
||||
@@ -108,14 +106,14 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="animate-slide-in-right flex flex-1 flex-col overflow-hidden">
|
||||
<div className="animate-slide-in-right flex flex-1 flex-col overflow-hidden px-3">
|
||||
{ActiveExtension ? (
|
||||
<ActiveExtension.component page={page} extensionData={ActiveExtension.data} storeType={storeType} />
|
||||
) : showNavigationTabs ? (
|
||||
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<PageNavigationPaneTabsList />
|
||||
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
|
||||
</Tab.Group>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { TabsContent } from "@plane/propel/tabs";
|
||||
// components
|
||||
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
|
||||
// plane web imports
|
||||
@@ -15,7 +17,6 @@ import type { TPageInstance } from "@/store/pages/base-page";
|
||||
import { PageNavigationPaneAssetsTabPanel } from "./assets";
|
||||
import { PageNavigationPaneInfoTabPanel } from "./info/root";
|
||||
import { PageNavigationPaneOutlineTabPanel } from "./outline";
|
||||
import { Tabs } from "@plane/propel/tabs";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
@@ -28,12 +29,16 @@ export function PageNavigationPaneTabPanelsRoot(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<Tabs.Content key={tab.key} value={tab.key} className="flex-1 overflow-hidden py-2">
|
||||
<TabsContent
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="vertical-scrollbar scrollbar-sm size-full overflow-y-auto outline-none"
|
||||
>
|
||||
{tab.key === "outline" && <PageNavigationPaneOutlineTabPanel page={page} />}
|
||||
{tab.key === "info" && <PageNavigationPaneInfoTabPanel page={page} versionHistory={versionHistory} />}
|
||||
{tab.key === "assets" && <PageNavigationPaneAssetsTabPanel page={page} />}
|
||||
<PageNavigationPaneAdditionalTabPanelsRoot activeTab={tab.key} page={page} />
|
||||
</Tabs.Content>
|
||||
</TabsContent>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TabsList, TabsTrigger } from "@plane/propel/tabs";
|
||||
// plane web components
|
||||
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
|
||||
|
||||
@@ -15,29 +15,12 @@ export function PageNavigationPaneTabsList() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tab.List className="relative mx-3.5 flex items-center rounded-md bg-layer-3 p-0.5">
|
||||
{({ selectedIndex }) => (
|
||||
<>
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className="relative z-[1] flex-1 py-1.5 text-13 font-semibold outline-none"
|
||||
>
|
||||
{t(tab.i18n_label)}
|
||||
</Tab>
|
||||
))}
|
||||
{/* active tab indicator */}
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-sm bg-layer-3-selected transition-all duration-500 ease-in-out"
|
||||
style={{
|
||||
left: `calc(${(selectedIndex / ORDERED_PAGE_NAVIGATION_TABS_LIST.length) * 100}% + 2px)`,
|
||||
height: "calc(100% - 4px)",
|
||||
width: `calc(${100 / ORDERED_PAGE_NAVIGATION_TABS_LIST.length}% - 4px)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Tab.List>
|
||||
<TabsList>
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<TabsTrigger key={tab.key} value={tab.key}>
|
||||
{t(tab.i18n_label)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ export const buttonVariants = cva(
|
||||
"error-fill":
|
||||
"bg-danger-primary text-on-color hover:bg-danger-primary-hover active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled",
|
||||
"error-outline":
|
||||
"bg-layer-2 hover:bg-danger-subtle active:bg-danger-subtle-hover disabled:bg-layer-2 text-danger-secondary disabled:text-disabled border border-danger-strong disabled:border-subtle-1",
|
||||
"border border-danger-strong bg-layer-2 text-danger-secondary hover:bg-danger-subtle active:bg-danger-subtle-hover disabled:border-subtle-1 disabled:bg-layer-2 disabled:text-disabled",
|
||||
secondary:
|
||||
"bg-layer-2 hover:bg-layer-2-hover active:bg-layer-2-active disabled:bg-layer-transparent text-secondary disabled:text-disabled border border-strong disabled:border-subtle-1 shadow-raised-100",
|
||||
"border border-strong bg-layer-2 text-secondary shadow-raised-100 hover:bg-layer-2-hover active:bg-layer-2-active disabled:border-subtle-1 disabled:bg-layer-transparent disabled:text-disabled",
|
||||
tertiary:
|
||||
"bg-layer-3 text-secondary hover:bg-layer-3-hover active:bg-layer-3-active disabled:bg-layer-transparent disabled:text-disabled",
|
||||
ghost:
|
||||
|
||||
@@ -1,3 +1,54 @@
|
||||
/**
|
||||
* Tabs Component
|
||||
*
|
||||
* A flexible tab navigation component built on top of Base UI's Tabs primitive.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Tabs } from "@plane/propel/tabs";
|
||||
*
|
||||
* // Basic usage
|
||||
* <Tabs defaultValue="tab1">
|
||||
* <Tabs.List>
|
||||
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="tab1">Content for Tab 1</Tabs.Content>
|
||||
* <Tabs.Content value="tab2">Content for Tab 2</Tabs.Content>
|
||||
* <Tabs.Content value="tab3">Content for Tab 3</Tabs.Content>
|
||||
* </Tabs>
|
||||
*
|
||||
* // With variant and size options
|
||||
* <Tabs defaultValue="overview" variant="contained">
|
||||
* <Tabs.List background="contained">
|
||||
* <Tabs.Trigger value="overview" size="sm">Overview</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="settings" size="sm">Settings</Tabs.Trigger>
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="overview">Overview content</Tabs.Content>
|
||||
* <Tabs.Content value="settings">Settings content</Tabs.Content>
|
||||
* </Tabs>
|
||||
*
|
||||
* // Controlled usage
|
||||
* const [activeTab, setActiveTab] = useState("tab1");
|
||||
*
|
||||
* <Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
* <Tabs.List>
|
||||
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="tab1">Content 1</Tabs.Content>
|
||||
* <Tabs.Content value="tab2">Content 2</Tabs.Content>
|
||||
* </Tabs>
|
||||
* ```
|
||||
*
|
||||
* @props
|
||||
* - `Tabs` (Root): Accepts all props from Base UI's Tabs.Root plus `variant`
|
||||
* - `Tabs.List`: Container for triggers. `background?: "contained"` adds bg color
|
||||
* - `Tabs.Trigger`: Tab button. `size?: "sm" | "md" | "lg"` controls text size
|
||||
* - `Tabs.Content`: Tab panel content
|
||||
* - `Tabs.Indicator`: Optional animated indicator element
|
||||
*/
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
@@ -138,6 +189,53 @@ const TabsIndicator = React.forwardRef(function TabsIndicator(
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A compound Tabs component built on Base UI primitives.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Tabs } from "@plane/propel";
|
||||
*
|
||||
* // Basic usage
|
||||
* <Tabs defaultValue="tab1">
|
||||
* <Tabs.List>
|
||||
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="tab1">Content for Tab 1</Tabs.Content>
|
||||
* <Tabs.Content value="tab2">Content for Tab 2</Tabs.Content>
|
||||
* <Tabs.Content value="tab3">Content for Tab 3</Tabs.Content>
|
||||
* </Tabs>
|
||||
*
|
||||
* // With size variants (sm | md | lg)
|
||||
* <Tabs defaultValue="tab1">
|
||||
* <Tabs.List>
|
||||
* <Tabs.Trigger value="tab1" size="sm">Small</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab2" size="md">Medium</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab3" size="lg">Large</Tabs.Trigger>
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="tab1">Small tab content</Tabs.Content>
|
||||
* <Tabs.Content value="tab2">Medium tab content</Tabs.Content>
|
||||
* <Tabs.Content value="tab3">Large tab content</Tabs.Content>
|
||||
* </Tabs>
|
||||
*
|
||||
* // With contained background variant
|
||||
* <Tabs defaultValue="tab1" variant="contained">
|
||||
* <Tabs.List background="contained">
|
||||
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
||||
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="tab1">Content 1</Tabs.Content>
|
||||
* <Tabs.Content value="tab2">Content 2</Tabs.Content>
|
||||
* </Tabs>
|
||||
* ```
|
||||
*
|
||||
* @property {Tabs.List} List - Container for tab triggers with optional background variant
|
||||
* @property {Tabs.Trigger} Trigger - Individual tab button with size variants (sm, md, lg)
|
||||
* @property {Tabs.Content} Content - Panel content associated with each tab
|
||||
* @property {Tabs.Indicator} Indicator - Animated indicator for active tab selection
|
||||
*/
|
||||
export const Tabs = Object.assign(TabsRoot, {
|
||||
List: TabsList,
|
||||
Trigger: TabsTrigger,
|
||||
|
||||
@@ -31,7 +31,6 @@ export * from "./scroll-area";
|
||||
export * from "./sortable";
|
||||
export * from "./spinners";
|
||||
export * from "./tables";
|
||||
export * from "./tabs";
|
||||
export * from "./tag";
|
||||
export * from "./tooltip";
|
||||
export * from "./typography";
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./tabs";
|
||||
export * from "./tab-list";
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Tab } from "@headlessui/react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
|
||||
export type TabListItem = {
|
||||
key: string;
|
||||
icon?: FC<LucideProps>;
|
||||
label?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type TTabListProps = {
|
||||
tabs: TabListItem[];
|
||||
tabListClassName?: string;
|
||||
tabClassName?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
selectedTab?: string;
|
||||
autoWrap?: boolean;
|
||||
onTabChange?: (key: string) => void;
|
||||
};
|
||||
|
||||
export function TabList({ autoWrap = true, ...props }: TTabListProps) {
|
||||
return autoWrap ? (
|
||||
<Tab.Group>
|
||||
<TabListInner {...props} />
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<TabListInner {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TabListInner({ tabs, tabListClassName, tabClassName, size = "md", selectedTab, onTabChange }: TTabListProps) {
|
||||
return (
|
||||
<Tab.List
|
||||
as="div"
|
||||
className={cn(
|
||||
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md bg-layer-1 p-0.5 text-13",
|
||||
tabListClassName
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"flex w-full min-w-fit cursor-pointer items-center justify-center rounded-sm p-1 font-medium text-primary transition-all outline-none focus:outline-none",
|
||||
(selectedTab ? selectedTab === tab.key : selected)
|
||||
? "shadow-sm bg-layer-transparent-active text-primary"
|
||||
: tab.disabled
|
||||
? "cursor-not-allowed text-placeholder"
|
||||
: "text-placeholder hover:bg-layer-transparent-hover hover:text-tertiary",
|
||||
{
|
||||
"text-11": size === "sm",
|
||||
"text-13": size === "md",
|
||||
"text-14": size === "lg",
|
||||
},
|
||||
tabClassName
|
||||
)
|
||||
}
|
||||
key={tab.key}
|
||||
onClick={() => {
|
||||
if (!tab.disabled) {
|
||||
onTabChange?.(tab.key);
|
||||
tab.onClick?.();
|
||||
}
|
||||
}}
|
||||
disabled={tab.disabled}
|
||||
>
|
||||
{tab.icon && (
|
||||
<tab.icon className={cn({ "size-3": size === "sm", "size-4": size === "md", "size-5": size === "lg" })} />
|
||||
)}
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Tab } from "@headlessui/react";
|
||||
import type { FC } from "react";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
// helpers
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { cn } from "../utils";
|
||||
// types
|
||||
import type { TabListItem } from "./tab-list";
|
||||
import { TabList } from "./tab-list";
|
||||
|
||||
export type TabContent = {
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
export type TabItem = TabListItem & TabContent;
|
||||
|
||||
type TTabsProps = {
|
||||
tabs: TabItem[];
|
||||
storageKey?: string;
|
||||
actions?: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
containerClassName?: string;
|
||||
tabListContainerClassName?: string;
|
||||
tabListClassName?: string;
|
||||
tabClassName?: string;
|
||||
tabPanelClassName?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
storeInLocalStorage?: boolean;
|
||||
};
|
||||
|
||||
export function Tabs(props: TTabsProps) {
|
||||
const {
|
||||
tabs,
|
||||
storageKey,
|
||||
actions,
|
||||
defaultTab = tabs[0]?.key,
|
||||
containerClassName = "",
|
||||
tabListContainerClassName = "",
|
||||
tabListClassName = "",
|
||||
tabClassName = "",
|
||||
tabPanelClassName = "",
|
||||
size = "md",
|
||||
storeInLocalStorage = true,
|
||||
} = props;
|
||||
// local storage
|
||||
const { storedValue, setValue } = useLocalStorage(
|
||||
storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`,
|
||||
defaultTab
|
||||
);
|
||||
// state
|
||||
const [selectedTab, setSelectedTab] = useState(storedValue ?? defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (storeInLocalStorage) {
|
||||
setValue(selectedTab);
|
||||
}
|
||||
}, [selectedTab, setValue, storeInLocalStorage, storageKey]);
|
||||
|
||||
const currentTabIndex = (tabKey: string): number => tabs.findIndex((tab) => tab.key === tabKey);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setSelectedTab(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Tab.Group defaultIndex={currentTabIndex(selectedTab)}>
|
||||
<div className={cn("flex h-full w-full flex-col gap-2", containerClassName)}>
|
||||
<div className={cn("flex w-full items-center gap-4", tabListContainerClassName)}>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
tabListClassName={tabListClassName}
|
||||
tabClassName={tabClassName}
|
||||
size={size}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
{actions && <div className="flex-grow">{actions}</div>}
|
||||
</div>
|
||||
<Tab.Panels as={Fragment}>
|
||||
{tabs.map((tab) => (
|
||||
<Tab.Panel key={tab.key} as="div" className={cn("relative outline-none", tabPanelClassName)}>
|
||||
{tab.content}
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</div>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user