chore: empty state revamp and loader improvement (#3448)
* chore: empty state asset added * chore: empty state asset updated and image path helper function added * chore: empty state asset updated * chore: empty state asset updated and empty state details constant added * chore: empty state component, helper function and comicbox button added * chore: draft, archived and project issue empty state * chore: cycle, module and issue layout empty state * chore: analytics, dashboard, all issues, pages and project view empty state * chore:projects empty state * chore:projects empty state improvement * chore: cycle, module, view and page loader improvement * chore: code refactor
@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useIssues, useProject } from "hooks/store";
|
||||
import { useCycle, useIssues, useProject, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { SingleProgressStats } from "components/core";
|
||||
@ -22,6 +22,7 @@ import {
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
import { ActiveCycleProgressStats } from "components/cycles";
|
||||
import { StateDropdown } from "components/dropdowns";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// icons
|
||||
import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-react";
|
||||
// helpers
|
||||
@ -32,7 +33,7 @@ import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
|
||||
import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||
import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||
|
||||
interface IActiveCycleDetails {
|
||||
workspaceSlug: string;
|
||||
@ -43,12 +44,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
// props
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const { currentUser } = useUser();
|
||||
const {
|
||||
issues: { fetchActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const {
|
||||
commandPalette: { toggleCreateCycleModal },
|
||||
} = useApplication();
|
||||
const {
|
||||
fetchActiveCycle,
|
||||
currentProjectActiveCycleId,
|
||||
@ -76,6 +75,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
: null
|
||||
);
|
||||
|
||||
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["active"];
|
||||
const emptyStateImage = getEmptyStateImagePath("cycle", "active", currentUser?.theme.theme === "light");
|
||||
|
||||
if (!activeCycle && isLoading)
|
||||
return (
|
||||
<Loader>
|
||||
@ -85,27 +87,12 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
if (!activeCycle)
|
||||
return (
|
||||
<div className="grid h-full place-items-center text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto flex justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="66" height="66" viewBox="0 0 66 66" fill="none">
|
||||
<circle cx="34.375" cy="34.375" r="22" stroke="rgb(var(--color-text-400))" strokeLinecap="round" />
|
||||
<path
|
||||
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
|
||||
fill="rgb(var(--color-text-400))"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-sm text-custom-text-200">No active cycle</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-custom-primary-100 outline-none"
|
||||
onClick={() => toggleCreateCycleModal(true)}
|
||||
>
|
||||
Create a new cycle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={emptyStateDetail.title}
|
||||
description={emptyStateDetail.description}
|
||||
image={emptyStateImage}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
|
||||
const endDate = new Date(activeCycle.end_date ?? "");
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useUser } from "hooks/store";
|
||||
// components
|
||||
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// constants
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
|
||||
|
||||
export interface ICyclesBoard {
|
||||
cycleIds: string[];
|
||||
@ -16,7 +19,10 @@ export interface ICyclesBoard {
|
||||
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
|
||||
const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props;
|
||||
// store hooks
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS[filter as keyof typeof CYCLE_EMPTY_STATE_DETAILS];
|
||||
const emptyStateImage = getEmptyStateImagePath("cycle", filter, currentUser?.theme.theme === "light");
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -41,27 +47,12 @@ export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto flex justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="66" height="66" viewBox="0 0 66 66" fill="none">
|
||||
<circle cx="34.375" cy="34.375" r="22" stroke="rgb(var(--color-text-400))" strokeLinecap="round" />
|
||||
<path
|
||||
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
|
||||
fill="rgb(var(--color-text-400))"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-sm text-custom-text-200">{filter === "all" ? "No cycles" : `No ${filter} cycles`}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-custom-primary-100 outline-none"
|
||||
onClick={() => commandPaletteStore.toggleCreateCycleModal(true)}
|
||||
>
|
||||
Create a new cycle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={emptyStateDetail.title}
|
||||
description={emptyStateDetail.description}
|
||||
image={emptyStateImage}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useUser } from "hooks/store";
|
||||
// components
|
||||
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// constants
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
|
||||
|
||||
export interface ICyclesList {
|
||||
cycleIds: string[];
|
||||
@ -17,10 +20,10 @@ export interface ICyclesList {
|
||||
export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
const { cycleIds, filter, workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: commandPaletteStore,
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS[filter as keyof typeof CYCLE_EMPTY_STATE_DETAILS];
|
||||
const emptyStateImage = getEmptyStateImagePath("cycle", filter, currentUser?.theme.theme === "light");
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -46,32 +49,12 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto flex justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="66" height="66" viewBox="0 0 66 66" fill="none">
|
||||
<circle cx="34.375" cy="34.375" r="22" stroke="rgb(var(--color-text-400))" strokeLinecap="round" />
|
||||
<path
|
||||
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
|
||||
fill="rgb(var(--color-text-400))"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-sm text-custom-text-200">
|
||||
{filter === "all" ? "No cycles" : `No ${filter} cycles`}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-custom-primary-100 outline-none"
|
||||
onClick={() => {
|
||||
setTrackElement("CYCLES_PAGE_EMPTY-STATE");
|
||||
commandPaletteStore.toggleCreateCycleModal(true);
|
||||
}}
|
||||
>
|
||||
Create a new cycle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={emptyStateDetail.title}
|
||||
description={emptyStateDetail.description}
|
||||
image={emptyStateImage}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
75
web/components/empty-state/comic-box-button.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// popper
|
||||
import { usePopper } from "react-popper";
|
||||
// helper
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
icon?: any;
|
||||
title: string | undefined;
|
||||
description: string | undefined;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ComicBoxButton: React.FC<Props> = (props) => {
|
||||
const { label, icon, title, description, onClick, disabled = false } = props;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-end",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 10],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Button ref={setReferenceElement} onClick={onClick} disabled={disabled}>
|
||||
<div className={`flex items-center gap-2.5 ${getButtonStyling("primary", "lg", disabled)}`}>
|
||||
{icon}
|
||||
<span className="leading-4">{label}</span>
|
||||
<span className="relative h-2 w-2">
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`absolute bg-blue-300 right-0 z-10 h-2.5 w-2.5 animate-ping rounded-full`}
|
||||
/>
|
||||
<div className={`absolute bg-blue-400/40 right-0 h-1.5 w-1.5 mt-0.5 mr-0.5 rounded-full`} />
|
||||
</span>
|
||||
</div>
|
||||
</Popover.Button>
|
||||
{isHovered && (
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100 p-5 relative min-w-80"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
static
|
||||
>
|
||||
<div className="absolute w-2 h-2 bg-custom-background-100 border rounded-lb-sm border-custom-border-200 border-r-0 border-t-0 transform rotate-45 bottom-2 -left-[5px]" />
|
||||
<h3 className="text-lg font-semibold w-full">{title}</h3>
|
||||
<h4 className="mt-1 text-sm">{description}</h4>
|
||||
</Popover.Panel>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
113
web/components/empty-state/empty-state.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React from "react";
|
||||
// components
|
||||
import { ComicBoxButton } from "./comic-box-button";
|
||||
// ui
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image: any;
|
||||
primaryButton?: {
|
||||
icon?: any;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
secondaryButton?: {
|
||||
icon?: any;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
comicBox?: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
size?: "sm" | "lg";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
comicBox,
|
||||
size = "sm",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const emptyStateHeader = (
|
||||
<>
|
||||
{description ? (
|
||||
<>
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
<p className="text-sm">{description}</p>
|
||||
</>
|
||||
) : (
|
||||
<h3 className="text-xl font-medium">{title}</h3>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const imageElement = <img src={image} sizes="100%" alt={primaryButton?.text || "button image"} />;
|
||||
|
||||
const secondaryButtonElement = secondaryButton && (
|
||||
<Button
|
||||
size={size === "sm" ? "md" : "lg"}
|
||||
variant="neutral-primary"
|
||||
prependIcon={secondaryButton.icon}
|
||||
onClick={secondaryButton.onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{secondaryButton.text}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 px-20">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5", {
|
||||
"min-w-[24rem] max-w-[38rem]": size === "sm",
|
||||
"min-w-[30rem] max-w-[60rem]": size === "lg",
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">{emptyStateHeader}</div>
|
||||
|
||||
{imageElement}
|
||||
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{primaryButton && (
|
||||
<>
|
||||
<div className="relative flex items-start justify-center">
|
||||
{comicBox ? (
|
||||
<ComicBoxButton
|
||||
label={primaryButton.text}
|
||||
icon={primaryButton.icon}
|
||||
title={comicBox?.title}
|
||||
description={comicBox?.description}
|
||||
onClick={() => primaryButton.onClick()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center gap-2.5 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} ${getButtonStyling("primary", "lg", disabled)}`}
|
||||
onClick={() => primaryButton.onClick()}
|
||||
>
|
||||
{primaryButton.icon}
|
||||
<span className="leading-4">{primaryButton.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{secondaryButton && secondaryButtonElement}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
2
web/components/empty-state/helper.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export const getEmptyStateImagePath = (category: string, type: string, isLightMode: boolean) =>
|
||||
`/empty-state/${category}/${type}-${isLightMode ? "light" : "dark"}.webp`;
|
3
web/components/empty-state/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./empty-state";
|
||||
export * from "./helper";
|
||||
export * from "./comic-box-button";
|
@ -0,0 +1,92 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import size from "lodash/size";
|
||||
// hooks
|
||||
import { useIssues, useUser } from "hooks/store";
|
||||
// components
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
comicBox?: { title: string; description: string };
|
||||
primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
|
||||
secondaryButton?: { text: string; onClick: () => void };
|
||||
size?: "lg" | "sm" | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ProjectArchivedEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
const currentLayoutEmptyStateImagePath = getEmptyStateImagePath(
|
||||
"empty-filters",
|
||||
activeLayout ?? "list",
|
||||
currentUser?.theme.theme === "light"
|
||||
);
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("archived", "empty-issues", currentUser?.theme.theme === "light");
|
||||
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([key, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = null;
|
||||
});
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
...newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const emptyStateProps: EmptyStateProps =
|
||||
issueFilterCount > 0
|
||||
? {
|
||||
title: "No issues found matching the filters applied",
|
||||
image: currentLayoutEmptyStateImagePath,
|
||||
secondaryButton: {
|
||||
text: "Clear all filters",
|
||||
onClick: handleClearAllFilters,
|
||||
},
|
||||
}
|
||||
: {
|
||||
title: "No archived issues yet",
|
||||
description:
|
||||
"Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.",
|
||||
image: EmptyStateImagePath,
|
||||
primaryButton: {
|
||||
text: "Set Automation",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`),
|
||||
},
|
||||
size: "sm",
|
||||
disabled: !isEditingAllowed,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<EmptyState {...emptyStateProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,88 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import size from "lodash/size";
|
||||
// hooks
|
||||
import { useIssues, useUser } from "hooks/store";
|
||||
// components
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
comicBox?: { title: string; description: string };
|
||||
primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
|
||||
secondaryButton?: { text: string; onClick: () => void };
|
||||
size?: "lg" | "sm" | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ProjectDraftEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.DRAFT);
|
||||
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
const currentLayoutEmptyStateImagePath = getEmptyStateImagePath(
|
||||
"empty-filters",
|
||||
activeLayout ?? "list",
|
||||
currentUser?.theme.theme === "light"
|
||||
);
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("draft", "empty-issues", currentUser?.theme.theme === "light");
|
||||
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([key, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = null;
|
||||
});
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
...newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const emptyStateProps: EmptyStateProps =
|
||||
issueFilterCount > 0
|
||||
? {
|
||||
title: "No issues found matching the filters applied",
|
||||
image: currentLayoutEmptyStateImagePath,
|
||||
secondaryButton: {
|
||||
text: "Clear all filters",
|
||||
onClick: handleClearAllFilters,
|
||||
},
|
||||
}
|
||||
: {
|
||||
title: "No draft issues yet",
|
||||
description:
|
||||
"Quickly stepping away but want to keep your place? No worries – save a draft now. Your issues will be right here waiting for you.",
|
||||
image: EmptyStateImagePath,
|
||||
size: "sm",
|
||||
disabled: !isEditingAllowed,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<EmptyState {...emptyStateProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
@ -2,4 +2,6 @@ export * from "./cycle";
|
||||
export * from "./global-view";
|
||||
export * from "./module";
|
||||
export * from "./project-view";
|
||||
export * from "./project";
|
||||
export * from "./project-issues";
|
||||
export * from "./draft-issues";
|
||||
export * from "./archived-issues";
|
||||
|
@ -0,0 +1,106 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import size from "lodash/size";
|
||||
// hooks
|
||||
import { useApplication, useIssues, useUser } from "hooks/store";
|
||||
// components
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
comicBox?: { title: string; description: string };
|
||||
primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
|
||||
secondaryButton?: { text: string; onClick: () => void };
|
||||
size?: "lg" | "sm" | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ProjectEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: commandPaletteStore,
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
||||
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
const currentLayoutEmptyStateImagePath = getEmptyStateImagePath(
|
||||
"empty-filters",
|
||||
activeLayout ?? "list",
|
||||
currentUser?.theme.theme === "light"
|
||||
);
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "issues", currentUser?.theme.theme === "light");
|
||||
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([key, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = null;
|
||||
});
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
...newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const emptyStateProps: EmptyStateProps =
|
||||
issueFilterCount > 0
|
||||
? {
|
||||
title: "No issues found matching the filters applied",
|
||||
image: currentLayoutEmptyStateImagePath,
|
||||
secondaryButton: {
|
||||
text: "Clear all filters",
|
||||
onClick: handleClearAllFilters,
|
||||
},
|
||||
}
|
||||
: {
|
||||
title: "Create an issue and assign it to someone, even yourself",
|
||||
description:
|
||||
"Think of issues as jobs, tasks, work, or JTBD. Which we like. An issue and its sub-issues are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes issues to move your project towards its goal.",
|
||||
image: EmptyStateImagePath,
|
||||
comicBox: {
|
||||
title: "Issues are building blocks in Plane.",
|
||||
description:
|
||||
"Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.",
|
||||
},
|
||||
primaryButton: {
|
||||
text: "Create your first issue",
|
||||
|
||||
onClick: () => {
|
||||
setTrackElement("PROJECT_EMPTY_STATE");
|
||||
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
||||
},
|
||||
},
|
||||
size: "lg",
|
||||
disabled: !isEditingAllowed,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<EmptyState {...emptyStateProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,49 +0,0 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// components
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
// assets
|
||||
import emptyIssue from "public/empty-state/empty_issues.webp";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export const ProjectEmptyState: React.FC = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: commandPaletteStore,
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<NewEmptyState
|
||||
title="Create an issue and assign it to someone, even yourself"
|
||||
description="Think of issues as jobs, tasks, work, or JTBD. Which we like. An issue and its sub-issues are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes issues to move your project towards its goal."
|
||||
image={emptyIssue}
|
||||
comicBox={{
|
||||
title: "Issues are building blocks in Plane.",
|
||||
direction: "left",
|
||||
description:
|
||||
"Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.",
|
||||
}}
|
||||
primaryButton={{
|
||||
text: "Create your first issue",
|
||||
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
|
||||
onClick: () => {
|
||||
setTrackElement("PROJECT_EMPTY_STATE");
|
||||
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
||||
},
|
||||
}}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -3,19 +3,22 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useGlobalView, useIssues, useUser } from "hooks/store";
|
||||
import { useApplication, useGlobalView, useIssues, useProject, useUser } from "hooks/store";
|
||||
import { useWorskspaceIssueProperties } from "hooks/use-worskspace-issue-properties";
|
||||
// components
|
||||
import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/issues";
|
||||
import { SpreadsheetView } from "components/issues/issue-layouts";
|
||||
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
import { ALL_ISSUES_EMPTY_STATE_DETAILS, EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
@ -24,6 +27,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
//swr hook for fetching issue properties
|
||||
useWorskspaceIssueProperties(workspaceSlug);
|
||||
// store
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const {
|
||||
issuesFilter: { filters, fetchFilters, updateFilters },
|
||||
issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue },
|
||||
@ -31,10 +35,17 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
const { dataViewId, issueIds } = groupedIssueIds;
|
||||
const {
|
||||
membership: { currentWorkspaceAllProjectsRole },
|
||||
membership: { currentWorkspaceAllProjectsRole, currentWorkspaceRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { fetchAllGlobalViews } = useGlobalView();
|
||||
// derived values
|
||||
const { workspaceProjectIds } = useProject();
|
||||
|
||||
const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(groupedIssueIds.dataViewId);
|
||||
const currentView = isDefaultView ? groupedIssueIds.dataViewId : "custom-view";
|
||||
const currentViewDetails = ALL_ISSUES_EMPTY_STATE_DETAILS[currentView as keyof typeof ALL_ISSUES_EMPTY_STATE_DETAILS];
|
||||
|
||||
const emptyStateImage = getEmptyStateImagePath("all-issues", currentView, currentUser?.theme.theme === "light");
|
||||
|
||||
useSWR(workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS${workspaceSlug}` : null, async () => {
|
||||
if (workspaceSlug) {
|
||||
@ -116,6 +127,8 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
[handleIssues]
|
||||
);
|
||||
|
||||
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{!globalViewId || globalViewId !== dataViewId || loader === "init-loader" || !issueIds ? (
|
||||
@ -127,7 +140,28 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} />
|
||||
|
||||
{(issueIds ?? {}).length == 0 ? (
|
||||
<>{/* <GlobalViewEmptyState /> */}</>
|
||||
<EmptyState
|
||||
image={emptyStateImage}
|
||||
title={(workspaceProjectIds ?? []).length > 0 ? currentViewDetails.title : "No project"}
|
||||
description={
|
||||
(workspaceProjectIds ?? []).length > 0
|
||||
? currentViewDetails.description
|
||||
: "To create issues or manage your work, you need to create a project or be a part of one."
|
||||
}
|
||||
size="sm"
|
||||
primaryButton={
|
||||
(workspaceProjectIds ?? []).length > 0
|
||||
? {
|
||||
text: "Create new issue",
|
||||
onClick: () => commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT),
|
||||
}
|
||||
: {
|
||||
text: "Start your first project",
|
||||
onClick: () => commandPaletteStore.toggleCreateProjectModal(true),
|
||||
}
|
||||
}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<SpreadsheetView
|
||||
|
@ -8,7 +8,7 @@ import { useIssues } from "hooks/store";
|
||||
import {
|
||||
ArchivedIssueListLayout,
|
||||
ArchivedIssueAppliedFiltersRoot,
|
||||
ProjectEmptyState,
|
||||
ProjectArchivedEmptyState,
|
||||
IssuePeekOverview,
|
||||
} from "components/issues";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
@ -48,8 +48,7 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
) : (
|
||||
<>
|
||||
{!issues?.groupedIssueIds ? (
|
||||
// TODO: Replace this with project view empty state
|
||||
<ProjectEmptyState />
|
||||
<ProjectArchivedEmptyState />
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
|
@ -7,7 +7,7 @@ import { useIssues } from "hooks/store";
|
||||
// components
|
||||
import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue";
|
||||
import { DraftIssueListLayout } from "../list/roots/draft-issue-root";
|
||||
import { ProjectEmptyState } from "../empty-states";
|
||||
import { ProjectDraftEmptyState } from "../empty-states";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root";
|
||||
@ -49,8 +49,7 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
) : (
|
||||
<>
|
||||
{!issues?.groupedIssueIds ? (
|
||||
// TODO: Replace this with project view empty state
|
||||
<ProjectEmptyState />
|
||||
<ProjectDraftEmptyState />
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
|
@ -1,18 +1,15 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useModule, useUser } from "hooks/store";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { Loader, Spinner } from "@plane/ui";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
// assets
|
||||
import emptyModule from "public/empty-state/empty_modules.webp";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
|
||||
export const ModulesListView: React.FC = observer(() => {
|
||||
// router
|
||||
@ -22,13 +19,23 @@ export const ModulesListView: React.FC = observer(() => {
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { projectModuleIds } = useModule();
|
||||
const { projectModuleIds, loader } = useModule();
|
||||
|
||||
const { storedValue: modulesView } = useLocalStorage("modules_view", "grid");
|
||||
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "modules", currentUser?.theme.theme === "light");
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
if (loader)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!projectModuleIds)
|
||||
return (
|
||||
<Loader className="grid grid-cols-3 gap-4 p-8">
|
||||
@ -84,21 +91,20 @@ export const ModulesListView: React.FC = observer(() => {
|
||||
{modulesView === "gantt_chart" && <ModulesListGanttChartView />}
|
||||
</>
|
||||
) : (
|
||||
<NewEmptyState
|
||||
<EmptyState
|
||||
title="Map your project milestones to Modules and track aggregated work easily."
|
||||
description="A group of issues that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by project milestones. They have their own periods and deadlines as well as analytics to help you see how close or far you are from a milestone."
|
||||
image={emptyModule}
|
||||
image={EmptyStateImagePath}
|
||||
comicBox={{
|
||||
title: "Modules help group work by hierarchy.",
|
||||
direction: "right",
|
||||
description:
|
||||
"A cart module, a chassis module, and a warehouse module are all good example of this grouping.",
|
||||
}}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "Build your first module",
|
||||
onClick: () => commandPaletteStore.toggleCreateModuleModal(true),
|
||||
}}
|
||||
size="lg"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
)}
|
||||
|
@ -6,20 +6,30 @@ import { useApplication, useDashboard, useProject, useUser } from "hooks/store";
|
||||
import { TourRoot } from "components/onboarding";
|
||||
import { UserGreetingsView } from "components/user";
|
||||
import { IssuePeekOverview } from "components/issues";
|
||||
import { DashboardProjectEmptyState, DashboardWidgets } from "components/dashboard";
|
||||
import { DashboardWidgets } from "components/dashboard";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
export const WorkspaceDashboardView = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: { toggleCreateProjectModal },
|
||||
eventTracker: { postHogEventTracker },
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { currentUser, updateTourCompleted } = useUser();
|
||||
const {
|
||||
currentUser,
|
||||
updateTourCompleted,
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
const emptyStateImage = getEmptyStateImagePath("onboarding", "dashboard", currentUser?.theme.theme === "light");
|
||||
|
||||
const handleTourCompleted = () => {
|
||||
updateTourCompleted()
|
||||
.then(() => {
|
||||
@ -41,6 +51,8 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
fetchHomeDashboardWidgets(workspaceSlug);
|
||||
}, [fetchHomeDashboardWidgets, workspaceSlug]);
|
||||
|
||||
const isEditingAllowed = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
@ -52,7 +64,27 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
{homeDashboardId && joinedProjectIds ? (
|
||||
<div className="space-y-7 p-7 bg-custom-background-90 h-full w-full flex flex-col overflow-y-auto">
|
||||
{currentUser && <UserGreetingsView user={currentUser} />}
|
||||
{joinedProjectIds.length > 0 ? <DashboardWidgets /> : <DashboardProjectEmptyState />}
|
||||
{joinedProjectIds.length > 0 ? (
|
||||
<DashboardWidgets />
|
||||
) : (
|
||||
<EmptyState
|
||||
image={emptyStateImage}
|
||||
title="Overview of your projects, activity, and metrics"
|
||||
description=" Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this
|
||||
page will transform into a space that helps you progress. Admins will also see items which help their team
|
||||
progress."
|
||||
primaryButton={{
|
||||
text: "Build your first project",
|
||||
onClick: () => toggleCreateProjectModal(true),
|
||||
}}
|
||||
comicBox={{
|
||||
title: "Everything starts with a project in Plane",
|
||||
description: "A project could be a product’s roadmap, a marketing campaign, or launching a new car.",
|
||||
}}
|
||||
size="lg"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
|
@ -1,17 +1,16 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
import { PagesListItem } from "./list-item";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// images
|
||||
import emptyPage from "public/empty-state/empty_page.png";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { PagesListItem } from "./list-item";
|
||||
import { PAGE_EMPTY_STATE_DETAILS } from "constants/page";
|
||||
|
||||
type IPagesListView = {
|
||||
pageIds: string[];
|
||||
@ -19,19 +18,32 @@ type IPagesListView = {
|
||||
|
||||
export const PagesListView: FC<IPagesListView> = (props) => {
|
||||
const { pageIds: projectPageIds } = props;
|
||||
// store hooks
|
||||
// trace(true);
|
||||
|
||||
const {
|
||||
commandPalette: { toggleCreatePageModal },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
// local storage
|
||||
const { storedValue: pageTab } = useLocalStorage("pageTab", "Recent");
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const currentPageTabDetails = pageTab
|
||||
? PAGE_EMPTY_STATE_DETAILS[pageTab as keyof typeof PAGE_EMPTY_STATE_DETAILS]
|
||||
: PAGE_EMPTY_STATE_DETAILS["All"];
|
||||
|
||||
const emptyStateImage = getEmptyStateImagePath(
|
||||
"pages",
|
||||
currentPageTabDetails.key,
|
||||
currentUser?.theme.theme === "light"
|
||||
);
|
||||
|
||||
const isButtonVisible = currentPageTabDetails.key !== "archived" && currentPageTabDetails.key !== "favorites";
|
||||
|
||||
// here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
@ -47,21 +59,18 @@ export const PagesListView: FC<IPagesListView> = (props) => {
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<NewEmptyState
|
||||
title="Write a note, a doc, or a full knowledge base. Get Galileo, Plane’s AI assistant, to help you get started."
|
||||
description="Pages are thoughtspotting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button."
|
||||
image={emptyPage}
|
||||
comicBox={{
|
||||
title: "A page can be a doc or a doc of docs.",
|
||||
description:
|
||||
"We wrote Parth and Meera’s love story. You could write your project’s mission, goals, and eventual vision.",
|
||||
direction: "right",
|
||||
}}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "Create your first page",
|
||||
onClick: () => toggleCreatePageModal(true),
|
||||
}}
|
||||
<EmptyState
|
||||
title={currentPageTabDetails.title}
|
||||
description={currentPageTabDetails.description}
|
||||
image={emptyStateImage}
|
||||
primaryButton={
|
||||
isButtonVisible
|
||||
? {
|
||||
text: "Create new page",
|
||||
onClick: () => toggleCreatePageModal(true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,29 +1,29 @@
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// assets
|
||||
import emptyPage from "public/empty-state/empty_page.png";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const RecentPagesList: FC = observer(() => {
|
||||
// store hooks
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { recentProjectPages } = useProjectPages();
|
||||
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("pages", "recent", currentUser?.theme.theme === "light");
|
||||
|
||||
// FIXME: replace any with proper type
|
||||
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);
|
||||
|
||||
@ -58,21 +58,15 @@ export const RecentPagesList: FC = observer(() => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NewEmptyState
|
||||
title="Write a note, a doc, or a full knowledge base. Get Galileo, Plane’s AI assistant, to help you get started."
|
||||
description="Pages are thoughtspotting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button."
|
||||
image={emptyPage}
|
||||
comicBox={{
|
||||
title: "A page can be a doc or a doc of docs.",
|
||||
description:
|
||||
"We wrote Parth and Meera’s love story. You could write your project’s mission, goals, and eventual vision.",
|
||||
direction: "right",
|
||||
}}
|
||||
<EmptyState
|
||||
title="Write a note, a doc, or a full knowledge base"
|
||||
description="Pages help you organise your thoughts to create wikis, discussions or even document heated takes for your project. Use it wisely! Pages will be sorted and grouped by last updated."
|
||||
image={EmptyStateImagePath}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "Create your first page",
|
||||
text: "Create new page",
|
||||
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
||||
}}
|
||||
size="sm"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</>
|
||||
|
@ -4,10 +4,9 @@ import { useApplication, useProject, useUser } from "hooks/store";
|
||||
// components
|
||||
import { ProjectCard } from "components/project";
|
||||
import { Loader } from "@plane/ui";
|
||||
// images
|
||||
import emptyProject from "public/empty-state/empty_project.webp";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// icons
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
import { Plus } from "lucide-react";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
@ -19,9 +18,12 @@ export const ProjectCardList = observer(() => {
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { workspaceProjectIds, searchedProjects, getProjectById } = useProject();
|
||||
|
||||
const emptyStateImage = getEmptyStateImagePath("onboarding", "projects", currentUser?.theme.theme === "light");
|
||||
|
||||
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
if (!workspaceProjectIds)
|
||||
@ -55,15 +57,10 @@ export const ProjectCardList = observer(() => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NewEmptyState
|
||||
image={emptyProject}
|
||||
<EmptyState
|
||||
image={emptyStateImage}
|
||||
title="Start a Project"
|
||||
description="Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal."
|
||||
comicBox={{
|
||||
title: "Everything starts with a project in Plane",
|
||||
direction: "right",
|
||||
description: "A project could be a product’s roadmap, a marketing campaign, or launching a new car.",
|
||||
}}
|
||||
primaryButton={{
|
||||
text: "Start your first project",
|
||||
onClick: () => {
|
||||
@ -71,6 +68,11 @@ export const ProjectCardList = observer(() => {
|
||||
commandPaletteStore.toggleCreateProjectModal(true);
|
||||
},
|
||||
}}
|
||||
comicBox={{
|
||||
title: "Everything starts with a project in Plane",
|
||||
description: "A project could be a product’s roadmap, a marketing campaign, or launching a new car.",
|
||||
}}
|
||||
size="lg"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useProjectView, useUser } from "hooks/store";
|
||||
// components
|
||||
import { ProjectViewListItem } from "components/views";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Input, Loader } from "@plane/ui";
|
||||
// assets
|
||||
import emptyView from "public/empty-state/empty_view.webp";
|
||||
import { Input, Loader, Spinner } from "@plane/ui";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
|
||||
@ -22,10 +20,16 @@ export const ProjectViewsList = observer(() => {
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { projectViewIds, getViewById } = useProjectView();
|
||||
const { projectViewIds, getViewById, loader } = useProjectView();
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
if (loader)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!projectViewIds)
|
||||
return (
|
||||
@ -39,8 +43,12 @@ export const ProjectViewsList = observer(() => {
|
||||
|
||||
const viewsList = projectViewIds.map((viewId) => getViewById(viewId));
|
||||
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "cycles", currentUser?.theme.theme === "light");
|
||||
|
||||
const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
{viewsList.length > 0 ? (
|
||||
@ -64,20 +72,19 @@ export const ProjectViewsList = observer(() => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NewEmptyState
|
||||
title="Save filtered views for your project. Create as many as you need."
|
||||
<EmptyState
|
||||
title="Save filtered views for your project. Create as many as you need"
|
||||
description="Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a project can see everyone’s views and choose whichever suits their needs best."
|
||||
image={emptyView}
|
||||
image={EmptyStateImagePath}
|
||||
comicBox={{
|
||||
title: "Views work atop Issue properties.",
|
||||
description: "You can create a view from here with as many properties as filters as you see fit.",
|
||||
direction: "right",
|
||||
}}
|
||||
primaryButton={{
|
||||
icon: <Plus size={14} strokeWidth={2} />,
|
||||
text: "Build your first view",
|
||||
text: "Create your first view",
|
||||
onClick: () => toggleCreateViewModal(true),
|
||||
}}
|
||||
size="lg"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
)}
|
||||
|
@ -114,3 +114,27 @@ export const CYCLE_STATE_GROUPS_DETAILS = [
|
||||
color: "#ef4444",
|
||||
},
|
||||
];
|
||||
|
||||
export const CYCLE_EMPTY_STATE_DETAILS = {
|
||||
active: {
|
||||
key: "active",
|
||||
title: "No active cycles",
|
||||
description:
|
||||
"An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.",
|
||||
},
|
||||
upcoming: {
|
||||
key: "upcoming",
|
||||
title: "No upcoming cycles",
|
||||
description: "Upcoming cycles on deck! Just add dates to cycles in draft, and they'll show up right here.",
|
||||
},
|
||||
completed: {
|
||||
key: "completed",
|
||||
title: "No completed cycles",
|
||||
description: "Any cycle with a past due date is considered completed. Explore all completed cycles here.",
|
||||
},
|
||||
draft: {
|
||||
key: "draft",
|
||||
title: "No draft cycles",
|
||||
description: "No dates added in cycles? Find them here as drafts.",
|
||||
},
|
||||
};
|
||||
|
@ -52,3 +52,38 @@ export const PAGE_ACCESS_SPECIFIERS: { key: number; label: string; icon: any }[]
|
||||
icon: Lock,
|
||||
},
|
||||
];
|
||||
|
||||
export const PAGE_EMPTY_STATE_DETAILS = {
|
||||
All: {
|
||||
key: "all",
|
||||
title: "Write a note, a doc, or a full knowledge base",
|
||||
description:
|
||||
"Pages help you organise your thoughts to create wikis, discussions or even document heated takes for your project. Use it wisely!",
|
||||
},
|
||||
Favorites: {
|
||||
key: "favorites",
|
||||
title: "No favorite pages yet",
|
||||
description: "Favorites for quick access? mark them and find them right here.",
|
||||
},
|
||||
Private: {
|
||||
key: "private",
|
||||
title: "No private pages yet",
|
||||
description: "Keep your private thoughts here. When you're ready to share, the team's just a click away.",
|
||||
},
|
||||
Shared: {
|
||||
key: "shared",
|
||||
title: "No shared pages yet",
|
||||
description: "See pages shared with everyone in your project right here.",
|
||||
},
|
||||
Archived: {
|
||||
key: "archived",
|
||||
title: "No archived pages yet",
|
||||
description: "Archive pages not on your radar. Access them here when needed.",
|
||||
},
|
||||
Recent: {
|
||||
key: "recent",
|
||||
title: "Write a note, a doc, or a full knowledge base",
|
||||
description:
|
||||
"Pages help you organise your thoughts to create wikis, discussions or even document heated takes for your project. Use it wisely! Pages will be sorted and grouped by last updated",
|
||||
},
|
||||
};
|
||||
|
@ -190,3 +190,31 @@ export const WORKSPACE_SETTINGS_LINKS: {
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_ISSUES_EMPTY_STATE_DETAILS = {
|
||||
"all-issues": {
|
||||
key: "all-issues",
|
||||
title: "No issues in the project",
|
||||
description: "First project done! Now, slice your work into trackable pieces with issues. Let's go!",
|
||||
},
|
||||
assigned: {
|
||||
key: "assigned",
|
||||
title: "No issues yet",
|
||||
description: "Issues assigned to you can be tracked from here.",
|
||||
},
|
||||
created: {
|
||||
key: "created",
|
||||
title: "No issues yet",
|
||||
description: "All issues created by you come here, track them here directly.",
|
||||
},
|
||||
subscribed: {
|
||||
key: "subscribed",
|
||||
title: "No issues yet",
|
||||
description: "Subscribe to issues you are interested in, track all of them here.",
|
||||
},
|
||||
"custom-view": {
|
||||
key: "custom-view",
|
||||
title: "No issues yet",
|
||||
description: "Issues that applies to the filters, track all of them here.",
|
||||
},
|
||||
};
|
||||
|
@ -8,11 +8,7 @@ import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
|
||||
import { WorkspaceAnalyticsHeader } from "components/headers";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
// icons
|
||||
import { Plus } from "lucide-react";
|
||||
// assets
|
||||
import emptyAnalytics from "public/empty-state/empty_analytics.webp";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// constants
|
||||
import { ANALYTICS_TABS } from "constants/analytics";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
@ -26,11 +22,13 @@ const AnalyticsPage: NextPageWithLayout = observer(() => {
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
membership: { currentWorkspaceRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "analytics", currentUser?.theme.theme === "light");
|
||||
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -63,29 +61,25 @@ const AnalyticsPage: NextPageWithLayout = observer(() => {
|
||||
</Tab.Group>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<NewEmptyState
|
||||
title="Track progress, workloads, and allocations. Spot trends, remove blockers, and move work faster."
|
||||
description="See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your project runs on time."
|
||||
image={emptyAnalytics}
|
||||
comicBox={{
|
||||
title: "Analytics works best with Cycles + Modules",
|
||||
description:
|
||||
"First, timebox your issues into Cycles and, if you can, group issues that span more than a cycle into Modules. Check out both on the left nav.",
|
||||
direction: "right",
|
||||
extraPadding: true,
|
||||
}}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "Create Cycles and Modules first",
|
||||
onClick: () => {
|
||||
setTrackElement("ANALYTICS_EMPTY_STATE");
|
||||
toggleCreateProjectModal(true);
|
||||
},
|
||||
}}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</>
|
||||
<EmptyState
|
||||
image={EmptyStateImagePath}
|
||||
title="Track progress, workloads, and allocations. Spot trends, remove blockers, and move work faster"
|
||||
description="See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your project runs on time."
|
||||
primaryButton={{
|
||||
text: "Create Cycles and Modules first",
|
||||
onClick: () => {
|
||||
setTrackElement("ANALYTICS_EMPTY_STATE");
|
||||
toggleCreateProjectModal(true);
|
||||
},
|
||||
}}
|
||||
comicBox={{
|
||||
title: "Analytics works best with Cycles + Modules",
|
||||
description:
|
||||
"First, timebox your issues into Cycles and, if you can, group issues that span more than a cycle into Modules. Check out both on the left nav.",
|
||||
}}
|
||||
size="lg"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -2,7 +2,6 @@ import { Fragment, useCallback, useState, ReactElement } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useCycle, useUser } from "hooks/store";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
@ -11,11 +10,9 @@ import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { CyclesHeader } from "components/headers";
|
||||
import { CyclesView, ActiveCycleDetails, CycleCreateUpdateModal } from "components/cycles";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// images
|
||||
import emptyCycle from "public/empty-state/empty_cycles.webp";
|
||||
import { Spinner, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { TCycleView, TCycleLayout } from "@plane/types";
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
@ -28,8 +25,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
const { currentProjectCycleIds } = useCycle();
|
||||
const { currentProjectCycleIds, loader } = useCycle();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||
@ -51,6 +49,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
},
|
||||
[handleCurrentLayout, setCycleTab]
|
||||
);
|
||||
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "cycles", currentUser?.theme.theme === "light");
|
||||
|
||||
const totalCycles = currentProjectCycleIds?.length ?? 0;
|
||||
|
||||
@ -58,6 +57,13 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
if (loader)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CycleCreateUpdateModal
|
||||
@ -68,23 +74,22 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
/>
|
||||
{totalCycles === 0 ? (
|
||||
<div className="h-full place-items-center">
|
||||
<NewEmptyState
|
||||
<EmptyState
|
||||
title="Group and timebox your work in Cycles."
|
||||
description="Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team."
|
||||
image={emptyCycle}
|
||||
image={EmptyStateImagePath}
|
||||
comicBox={{
|
||||
title: "Cycles are repetitive time-boxes.",
|
||||
direction: "right",
|
||||
description:
|
||||
"A sprint, an iteration, and or any other term you use for weekly or fortnightly tracking of work is a cycle.",
|
||||
}}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "Set your first cycle",
|
||||
onClick: () => {
|
||||
setCreateModal(true);
|
||||
},
|
||||
}}
|
||||
size="lg"
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@ import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
|
||||
import { PagesHeader } from "components/headers";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// types
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
// constants
|
||||
@ -48,7 +49,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
||||
// store
|
||||
const { currentUser, currentUserLoader } = useUser();
|
||||
|
||||
const { fetchProjectPages, fetchArchivedProjectPages } = useProjectPages();
|
||||
const { fetchProjectPages, fetchArchivedProjectPages, loader } = useProjectPages();
|
||||
// hooks
|
||||
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
|
||||
// local storage
|
||||
@ -83,6 +84,13 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
if (loader)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && (
|
||||
|
BIN
web/public/empty-state/all-issues/all-issues-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/all-issues/all-issues-light.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
web/public/empty-state/all-issues/assigned-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/all-issues/assigned-light.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
web/public/empty-state/all-issues/created-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/all-issues/created-light.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
web/public/empty-state/all-issues/custom-view-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/all-issues/custom-view-light.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
web/public/empty-state/all-issues/no-project-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/all-issues/no-project-light.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
web/public/empty-state/all-issues/subscribed-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/all-issues/subscribed-light.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
web/public/empty-state/archived/empty-issues-dark.webp
Normal file
After Width: | Height: | Size: 132 KiB |
BIN
web/public/empty-state/archived/empty-issues-light.webp
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
web/public/empty-state/cycle/active-dark.webp
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
web/public/empty-state/cycle/active-light.webp
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
web/public/empty-state/cycle/completed-dark.webp
Normal file
After Width: | Height: | Size: 113 KiB |
BIN
web/public/empty-state/cycle/completed-light.webp
Normal file
After Width: | Height: | Size: 119 KiB |
BIN
web/public/empty-state/cycle/draft-dark.webp
Normal file
After Width: | Height: | Size: 89 KiB |
BIN
web/public/empty-state/cycle/draft-light.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
web/public/empty-state/cycle/upcoming-dark.webp
Normal file
After Width: | Height: | Size: 112 KiB |
BIN
web/public/empty-state/cycle/upcoming-light.webp
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
web/public/empty-state/draft/empty-issues-dark.webp
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
web/public/empty-state/draft/empty-issues-light.webp
Normal file
After Width: | Height: | Size: 89 KiB |
BIN
web/public/empty-state/empty-filters/calendar-dark.webp
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
web/public/empty-state/empty-filters/calendar-light.webp
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
web/public/empty-state/empty-filters/gantt_chart-dark.webp
Normal file
After Width: | Height: | Size: 122 KiB |
BIN
web/public/empty-state/empty-filters/gantt_chart-light.webp
Normal file
After Width: | Height: | Size: 111 KiB |
BIN
web/public/empty-state/empty-filters/kanban-dark.webp
Normal file
After Width: | Height: | Size: 152 KiB |
BIN
web/public/empty-state/empty-filters/kanban-light.webp
Normal file
After Width: | Height: | Size: 149 KiB |
BIN
web/public/empty-state/empty-filters/list-dark.webp
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
web/public/empty-state/empty-filters/list-light.webp
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
web/public/empty-state/empty-filters/spreadsheet-dark.webp
Normal file
After Width: | Height: | Size: 108 KiB |
BIN
web/public/empty-state/empty-filters/spreadsheet-light.webp
Normal file
After Width: | Height: | Size: 106 KiB |
BIN
web/public/empty-state/onboarding/analytics-dark.webp
Normal file
After Width: | Height: | Size: 160 KiB |
BIN
web/public/empty-state/onboarding/analytics-light.webp
Normal file
After Width: | Height: | Size: 157 KiB |
BIN
web/public/empty-state/onboarding/cycles-dark.webp
Normal file
After Width: | Height: | Size: 206 KiB |
BIN
web/public/empty-state/onboarding/cycles-light.webp
Normal file
After Width: | Height: | Size: 209 KiB |
BIN
web/public/empty-state/onboarding/dashboard-dark.webp
Normal file
After Width: | Height: | Size: 184 KiB |
BIN
web/public/empty-state/onboarding/dashboard-light.webp
Normal file
After Width: | Height: | Size: 197 KiB |
BIN
web/public/empty-state/onboarding/issues-dark.webp
Normal file
After Width: | Height: | Size: 231 KiB |
BIN
web/public/empty-state/onboarding/issues-light.webp
Normal file
After Width: | Height: | Size: 227 KiB |
BIN
web/public/empty-state/onboarding/modules-dark.webp
Normal file
After Width: | Height: | Size: 193 KiB |
BIN
web/public/empty-state/onboarding/modules-light.webp
Normal file
After Width: | Height: | Size: 197 KiB |
BIN
web/public/empty-state/onboarding/pages-dark.webp
Normal file
After Width: | Height: | Size: 219 KiB |
BIN
web/public/empty-state/onboarding/pages-light.webp
Normal file
After Width: | Height: | Size: 210 KiB |
BIN
web/public/empty-state/onboarding/projects-dark.webp
Normal file
After Width: | Height: | Size: 418 KiB |
BIN
web/public/empty-state/onboarding/projects-light.webp
Normal file
After Width: | Height: | Size: 412 KiB |
BIN
web/public/empty-state/onboarding/views-dark.webp
Normal file
After Width: | Height: | Size: 145 KiB |
BIN
web/public/empty-state/onboarding/views-light.webp
Normal file
After Width: | Height: | Size: 144 KiB |
BIN
web/public/empty-state/pages/all-dark.webp
Normal file
After Width: | Height: | Size: 115 KiB |
BIN
web/public/empty-state/pages/all-light.webp
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
web/public/empty-state/pages/archived-dark.webp
Normal file
After Width: | Height: | Size: 63 KiB |
BIN
web/public/empty-state/pages/archived-light.webp
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
web/public/empty-state/pages/favorites-dark.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
web/public/empty-state/pages/favorites-light.webp
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
web/public/empty-state/pages/private-dark.webp
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
web/public/empty-state/pages/private-light.webp
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
web/public/empty-state/pages/recent-dark.webp
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
web/public/empty-state/pages/recent-light.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
web/public/empty-state/pages/shared-dark.webp
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
web/public/empty-state/pages/shared-light.webp
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
web/public/empty-state/profile/activities-dark.webp
Normal file
After Width: | Height: | Size: 588 KiB |
BIN
web/public/empty-state/profile/activities-light.webp
Normal file
After Width: | Height: | Size: 591 KiB |
BIN
web/public/empty-state/profile/assigned-dark.webp
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
web/public/empty-state/profile/assigned-light.webp
Normal file
After Width: | Height: | Size: 109 KiB |
BIN
web/public/empty-state/profile/created-dark.webp
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
web/public/empty-state/profile/created-light.webp
Normal file
After Width: | Height: | Size: 109 KiB |
BIN
web/public/empty-state/profile/issues-by-priority-dark.webp
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
web/public/empty-state/profile/issues-by-priority-light.webp
Normal file
After Width: | Height: | Size: 199 KiB |
BIN
web/public/empty-state/profile/issues-by-state-dark.webp
Normal file
After Width: | Height: | Size: 213 KiB |
BIN
web/public/empty-state/profile/issues-by-state-light.webp
Normal file
After Width: | Height: | Size: 213 KiB |
BIN
web/public/empty-state/profile/subscribed-dark.webp
Normal file
After Width: | Height: | Size: 107 KiB |