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
This commit is contained in:
Anmol Singh Bhatia 2024-01-24 19:12:54 +05:30 committed by GitHub
parent 1a1594e818
commit 87f39d7372
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 897 additions and 285 deletions

View File

@ -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 ?? "");

View File

@ -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"
/>
)}
</>
);

View File

@ -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"
/>
)}
</>
) : (

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,2 @@
export const getEmptyStateImagePath = (category: string, type: string, isLightMode: boolean) =>
`/empty-state/${category}/${type}-${isLightMode ? "light" : "dark"}.webp`;

View File

@ -0,0 +1,3 @@
export * from "./empty-state";
export * from "./helper";
export * from "./comic-box-button";

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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";

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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

View File

@ -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">

View File

@ -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" ? (

View File

@ -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}
/>
)}

View File

@ -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 products roadmap, a marketing campaign, or launching a new car.",
}}
size="lg"
disabled={!isEditingAllowed}
/>
)}
</div>
) : (
<div className="h-full w-full grid place-items-center">

View File

@ -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, Planes 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 projects context. To make short work of any doc, invoke Galileo, Planes 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 Meeras love story. You could write your projects 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}
/>
)}

View File

@ -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, Planes 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 projects context. To make short work of any doc, invoke Galileo, Planes 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 Meeras love story. You could write your projects 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}
/>
</>

View File

@ -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 products 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 products roadmap, a marketing campaign, or launching a new car.",
}}
size="lg"
disabled={!isEditingAllowed}
/>
)}

View File

@ -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 everyones 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}
/>
)}

View File

@ -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.",
},
};

View File

@ -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",
},
};

View File

@ -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.",
},
};

View File

@ -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}
/>
)}
</>
);

View File

@ -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>

View File

@ -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 && (

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Some files were not shown because too many files have changed in this diff Show More