chore: empty state and project active cycle improvement (#3472)

* chore: pages empty state improvement

* chore: workspace all issues empty state improvement

* chore: profile issue empty state improvement

* chore: empty state sm size updated

* chore: project view empty state image updated

* chore: dashboard widgets permission uodated

* chore: draft issues and project issue empty state image

* chore: active cycle label updated
This commit is contained in:
Anmol Singh Bhatia 2024-01-25 18:00:45 +05:30 committed by GitHub
parent 5c912b8821
commit 6c6b764421
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 215 additions and 131 deletions

View File

@ -138,6 +138,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}); });
}; };
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date());
return ( return (
<div> <div>
<CycleCreateUpdateModal <CycleCreateUpdateModal
@ -177,7 +179,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}} }}
> >
{currentCycle.value === "current" {currentCycle.value === "current"
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}` ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`} : `${currentCycle.label}`}
</span> </span>
)} )}

View File

@ -141,6 +141,8 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date());
return ( return (
<> <>
<CycleCreateUpdateModal <CycleCreateUpdateModal
@ -202,7 +204,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
}} }}
> >
{currentCycle.value === "current" {currentCycle.value === "current"
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}` ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`} : `${currentCycle.label}`}
</span> </span>
)} )}

View File

@ -78,7 +78,7 @@ export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
const { fetchWidgetStats, getWidgetStats } = useDashboard(); const { fetchWidgetStats, getWidgetStats } = useDashboard();
// derived values // derived values
const widgetStats = getWidgetStats<TRecentProjectsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TRecentProjectsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; const canCreateProject = currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
useEffect(() => { useEffect(() => {
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {

View File

@ -68,7 +68,7 @@ export const EmptyState: React.FC<Props> = ({
<div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 px-20"> <div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 px-20">
<div <div
className={cn("flex flex-col gap-5", { className={cn("flex flex-col gap-5", {
"min-w-[24rem] max-w-[38rem]": size === "sm", "min-w-[24rem] max-w-[45rem]": size === "sm",
"min-w-[30rem] max-w-[60rem]": size === "lg", "min-w-[30rem] max-w-[60rem]": size === "lg",
})} })}
> >

View File

@ -187,10 +187,12 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
size="sm" size="sm"
primaryButton={ primaryButton={
(workspaceProjectIds ?? []).length > 0 (workspaceProjectIds ?? []).length > 0
? { ? currentView !== "custom-view" && currentView !== "subscribed"
text: "Create new issue", ? {
onClick: () => commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT), text: "Create new issue",
} onClick: () => commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT),
}
: undefined
: { : {
text: "Start your first project", text: "Start your first project",
onClick: () => commandPaletteStore.toggleCreateProjectModal(true), onClick: () => commandPaletteStore.toggleCreateProjectModal(true),

View File

@ -9,9 +9,9 @@ import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const ArchivedPagesList: FC = observer(() => { export const ArchivedPagesList: FC = observer(() => {
const projectPageStore = useProjectPages(); const projectPageStore = useProjectPages();
const { archivedPageIds, archivedProjectLoader } = projectPageStore; const { archivedPageIds, archivedPageLoader } = projectPageStore;
if (archivedProjectLoader) { if (archivedPageLoader) {
return ( return (
<div className="flex items-center justify-center h-full w-full"> <div className="flex items-center justify-center h-full w-full">
<Spinner /> <Spinner />

View File

@ -5,45 +5,21 @@ import Link from "next/link";
// components // components
import { ProfileIssuesFilter } from "components/profile"; import { ProfileIssuesFilter } from "components/profile";
// constants
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "constants/profile";
type Props = { type Props = {
isAuthorized: boolean; isAuthorized: boolean;
showProfileIssuesFilter?: boolean; showProfileIssuesFilter?: boolean;
}; };
const viewerTabs = [
{
route: "",
label: "Summary",
selected: "/[workspaceSlug]/profile/[userId]",
},
];
const adminTabs = [
{
route: "assigned",
label: "Assigned",
selected: "/[workspaceSlug]/profile/[userId]/assigned",
},
{
route: "created",
label: "Created",
selected: "/[workspaceSlug]/profile/[userId]/created",
},
{
route: "subscribed",
label: "Subscribed",
selected: "/[workspaceSlug]/profile/[userId]/subscribed",
},
];
export const ProfileNavbar: React.FC<Props> = (props) => { export const ProfileNavbar: React.FC<Props> = (props) => {
const { isAuthorized, showProfileIssuesFilter } = props; const { isAuthorized, showProfileIssuesFilter } = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, userId } = router.query; const { workspaceSlug, userId } = router.query;
const tabsList = isAuthorized ? [...viewerTabs, ...adminTabs] : viewerTabs; const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
return ( return (
<div className="sticky -top-0.5 z-10 flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static"> <div className="sticky -top-0.5 z-10 flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static">

View File

@ -7,8 +7,12 @@ import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/ro
import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root"; import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root";
import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues"; import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues";
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// hooks // hooks
import { useIssues } from "hooks/store"; import { useIssues, useUser } from "hooks/store";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { PROFILE_EMPTY_STATE_DETAILS } from "constants/profile";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
interface IProfileIssuesPage { interface IProfileIssuesPage {
@ -23,7 +27,10 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
workspaceSlug: string; workspaceSlug: string;
userId: string; userId: string;
}; };
const {
membership: { currentWorkspaceRole },
currentUser,
} = useUser();
const { const {
issues: { loader, groupedIssueIds, fetchIssues }, issues: { loader, groupedIssueIds, fetchIssues },
issuesFilter: { issueFilters, fetchFilters }, issuesFilter: { issueFilters, fetchFilters },
@ -39,8 +46,12 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
} }
); );
const emptyStateImage = getEmptyStateImagePath("profile", type, currentUser?.theme.theme === "light");
const activeLayout = issueFilters?.displayFilters?.layout || undefined; const activeLayout = issueFilters?.displayFilters?.layout || undefined;
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>
{loader === "init-loader" ? ( {loader === "init-loader" ? (
@ -49,16 +60,28 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
</div> </div>
) : ( ) : (
<> <>
<ProfileIssuesAppliedFiltersRoot /> {groupedIssueIds ? (
<div className="-z-1 relative h-full w-full overflow-auto"> <>
{activeLayout === "list" ? ( <ProfileIssuesAppliedFiltersRoot />
<ProfileIssuesListLayout /> <div className="-z-1 relative h-full w-full overflow-auto">
) : activeLayout === "kanban" ? ( {activeLayout === "list" ? (
<ProfileIssuesKanBanLayout /> <ProfileIssuesListLayout />
) : null} ) : activeLayout === "kanban" ? (
</div> <ProfileIssuesKanBanLayout />
{/* peek overview */} ) : null}
<IssuePeekOverview /> </div>
{/* peek overview */}
<IssuePeekOverview />
</>
) : (
<EmptyState
image={emptyStateImage}
title={PROFILE_EMPTY_STATE_DETAILS[type].title}
description={PROFILE_EMPTY_STATE_DETAILS[type].description}
size="sm"
disabled={!isEditingAllowed}
/>
)}
</> </>
)} )}
</> </>

View File

@ -43,7 +43,7 @@ export const ProjectViewsList = observer(() => {
const viewsList = projectViewIds.map((viewId) => getViewById(viewId)); const viewsList = projectViewIds.map((viewId) => getViewById(viewId));
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "cycles", currentUser?.theme.theme === "light"); const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "views", currentUser?.theme.theme === "light");
const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(query.toLowerCase())); const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(query.toLowerCase()));

View File

@ -38,3 +38,47 @@ export const PROFILE_ACTION_LINKS: {
Icon: Settings2, Icon: Settings2,
}, },
]; ];
export const PROFILE_VIEWER_TAB = [
{
route: "",
label: "Summary",
selected: "/[workspaceSlug]/profile/[userId]",
},
];
export const PROFILE_ADMINS_TAB = [
{
route: "assigned",
label: "Assigned",
selected: "/[workspaceSlug]/profile/[userId]/assigned",
},
{
route: "created",
label: "Created",
selected: "/[workspaceSlug]/profile/[userId]/created",
},
{
route: "subscribed",
label: "Subscribed",
selected: "/[workspaceSlug]/profile/[userId]/subscribed",
},
];
export const PROFILE_EMPTY_STATE_DETAILS = {
assigned: {
key: "assigned",
title: "No issues are assigned to you",
description: "Issues assigned to you can be tracked from here.",
},
subscribed: {
key: "created",
title: "No issues yet",
description: "All issues created by you come here, track them here directly.",
},
created: {
key: "subscribed",
title: "No issues yet",
description: "Subscribe to issues you are interested in, track all of them here.",
},
};

View File

@ -5,13 +5,14 @@ import { Tab } from "@headlessui/react";
import useSWR from "swr"; import useSWR from "swr";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage"; import useLocalStorage from "hooks/use-local-storage";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
// components // components
import { RecentPagesList, CreateUpdatePageModal } from "components/pages"; import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
import { PagesHeader } from "components/headers"; import { PagesHeader } from "components/headers";
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// types // types
@ -19,6 +20,7 @@ import { NextPageWithLayout } from "lib/types";
// constants // constants
import { PAGE_TABS_LIST } from "constants/page"; import { PAGE_TABS_LIST } from "constants/page";
import { useProjectPages } from "hooks/store/use-project-page"; import { useProjectPages } from "hooks/store/use-project-page";
import { EUserWorkspaceRoles } from "constants/workspace";
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), { const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
ssr: false, ssr: false,
@ -47,9 +49,17 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
// states // states
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
// store // store
const { currentUser, currentUserLoader } = useUser(); const {
currentUser,
currentUserLoader,
membership: { currentProjectRole },
} = useUser();
const {
commandPalette: { toggleCreatePageModal },
} = useApplication();
const { fetchProjectPages, fetchArchivedProjectPages, loader } = useProjectPages(); const { fetchProjectPages, fetchArchivedProjectPages, loader, archivedPageLoader, projectPageIds, archivedPageIds } =
useProjectPages();
// hooks // hooks
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader }); const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
// local storage // local storage
@ -84,7 +94,11 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
} }
}; };
if (loader) const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "pages", currentUser?.theme.theme === "light");
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
if (loader || archivedPageLoader)
return ( return (
<div className="flex items-center justify-center h-full w-full"> <div className="flex items-center justify-center h-full w-full">
<Spinner /> <Spinner />
@ -93,79 +107,100 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
return ( return (
<> <>
{workspaceSlug && projectId && ( {projectPageIds && archivedPageIds && projectPageIds.length + archivedPageIds.length > 0 ? (
<CreateUpdatePageModal <>
isOpen={createUpdatePageModal} {workspaceSlug && projectId && (
handleClose={() => setCreateUpdatePageModal(false)} <CreateUpdatePageModal
projectId={projectId.toString()} isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
projectId={projectId.toString()}
/>
)}
<div className="flex h-full flex-col space-y-5 overflow-hidden p-6">
<div className="flex justify-between gap-4">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
</div>
<Tab.Group
as={Fragment}
defaultIndex={currentTabValue(pageTab)}
onChange={(i) => {
switch (i) {
case 0:
return setPageTab("Recent");
case 1:
return setPageTab("All");
case 2:
return setPageTab("Favorites");
case 3:
return setPageTab("Private");
case 4:
return setPageTab("Shared");
case 5:
return setPageTab("Archived");
default:
return setPageTab("All");
}
}}
>
<Tab.List as="div" className="mb-6 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-4">
{PAGE_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-full border px-5 py-1.5 text-sm outline-none ${
selected
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90"
}`
}
>
{tab.title}
</Tab>
))}
</div>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="h-full space-y-5 overflow-y-auto">
<RecentPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<AllPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<FavoritePagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<PrivatePagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<SharedPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<ArchivedPagesList />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</>
) : (
<EmptyState
image={EmptyStateImagePath}
title="Write a note, a doc, or a full knowledge base. Get Galileo, Planes AI assistant, to help you get started"
description="Pages are thoughts potting 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."
primaryButton={{
text: "Create your first page",
onClick: () => toggleCreatePageModal(true),
}}
comicBox={{
title: "A page can be a doc or a doc of docs.",
description:
"We wrote Nikhil and Meeras love story. You could write your projects mission, goals, and eventual vision.",
}}
size="lg"
disabled={!isEditingAllowed}
/> />
)} )}
<div className="flex h-full flex-col space-y-5 overflow-hidden p-6">
<div className="flex justify-between gap-4">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
</div>
<Tab.Group
as={Fragment}
defaultIndex={currentTabValue(pageTab)}
onChange={(i) => {
switch (i) {
case 0:
return setPageTab("Recent");
case 1:
return setPageTab("All");
case 2:
return setPageTab("Favorites");
case 3:
return setPageTab("Private");
case 4:
return setPageTab("Shared");
case 5:
return setPageTab("Archived");
default:
return setPageTab("All");
}
}}
>
<Tab.List as="div" className="mb-6 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-4">
{PAGE_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-full border px-5 py-1.5 text-sm outline-none ${
selected
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90"
}`
}
>
{tab.title}
</Tab>
))}
</div>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="h-full space-y-5 overflow-y-auto">
<RecentPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<AllPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<FavoritePagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<PrivatePagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<SharedPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<ArchivedPagesList />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</> </>
); );
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 231 KiB

View File

@ -11,7 +11,7 @@ import { isThisWeek, isToday, isYesterday } from "date-fns";
export interface IProjectPageStore { export interface IProjectPageStore {
loader: boolean; loader: boolean;
archivedProjectLoader: boolean; archivedPageLoader: boolean;
projectPageMap: Record<string, Record<string, IPageStore>>; projectPageMap: Record<string, Record<string, IPageStore>>;
projectArchivedPageMap: Record<string, Record<string, IPageStore>>; projectArchivedPageMap: Record<string, Record<string, IPageStore>>;
@ -33,7 +33,7 @@ export interface IProjectPageStore {
export class ProjectPageStore implements IProjectPageStore { export class ProjectPageStore implements IProjectPageStore {
loader: boolean = false; loader: boolean = false;
archivedProjectLoader: boolean = false; archivedPageLoader: boolean = false;
projectPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] } projectPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
projectArchivedPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] } projectArchivedPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
@ -44,7 +44,7 @@ export class ProjectPageStore implements IProjectPageStore {
constructor(_rootStore: RootStore) { constructor(_rootStore: RootStore) {
makeObservable(this, { makeObservable(this, {
loader: observable.ref, loader: observable.ref,
archivedProjectLoader: observable.ref, archivedPageLoader: observable.ref,
projectPageMap: observable, projectPageMap: observable,
projectArchivedPageMap: observable, projectArchivedPageMap: observable,
@ -183,18 +183,18 @@ export class ProjectPageStore implements IProjectPageStore {
*/ */
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => { fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => {
try { try {
this.archivedProjectLoader = true; this.archivedPageLoader = true;
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => { runInAction(() => {
for (const page of response) { for (const page of response) {
set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore)); set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
} }
this.archivedProjectLoader = false; this.archivedPageLoader = false;
}); });
return response; return response;
}); });
} catch (e) { } catch (e) {
this.archivedProjectLoader = false; this.archivedPageLoader = false;
throw e; throw e;
} }
}; };