feat: dashboard widgets (#3362)
* fix: created dashboard, widgets and dashboard widget model * fix: new user home dashboard * chore: recent projects list * chore: recent collaborators * chore: priority order change * chore: payload changes * chore: collaborator's active issue count * chore: all dashboard widgets added with services and typs * chore: centered metric for pie chart * chore: widget filters * chore: created issue filter * fix: created and assigned issues payload change * chore: created issue payload change * fix: date filter change * chore: implement filters * fix: added expansion fields * fix: changed issue structure with relation * chore: new issues response * fix: project member fix * chore: updated issue_relation structure * chore: code cleanup * chore: update issues response and added empty states * fix: button text wrap * chore: update empty state messages * fix: filters * chore: update dark mode empty states * build-error: Type check in the issue relation service * fix: issues redirection * fix: project empty state * chore: project member active check * chore: project member check in state and priority * chore: remove console logs and replace harcoded values with constants * fix: code refactoring * fix: key name changed * refactor: mapping through similar components using an array * fix: build errors --------- Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com>
This commit is contained in:
parent
f347c1cd69
commit
c9337d4a41
122 changed files with 6790 additions and 849 deletions
119
web/components/dashboard/widgets/assigned-issues.tsx
Normal file
119
web/components/dashboard/widgets/assigned-issues.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||
|
||||
const WIDGET_KEY = "assigned_issues";
|
||||
|
||||
export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
fetchWidgetStats,
|
||||
widgetDetails: allWidgetDetails,
|
||||
widgetStats: allWidgetStats,
|
||||
updateDashboardWidgetFilters,
|
||||
} = useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TAssignedIssuesWidgetResponse;
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
expand: "issue_relation",
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
const filterDates = getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week");
|
||||
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||
target_date: filterDates,
|
||||
expand: "issue_relation",
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||
|
||||
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
||||
const redirectionLink = `/${workspaceSlug}/workspace-views/assigned/${filterParams}`;
|
||||
|
||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col">
|
||||
<Link href={redirectionLink} className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">All issues assigned</h4>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
||||
onChange={(i) => {
|
||||
const selectedTab = ISSUES_TABS_LIST[i];
|
||||
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<div className="px-6">
|
||||
<TabsList />
|
||||
</div>
|
||||
<Tab.Panels as="div" className="mt-7 h-full">
|
||||
{ISSUES_TABS_LIST.map((tab) => (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
||||
<WidgetIssuesList
|
||||
filter={widgetDetails.widget_filters.target_date}
|
||||
issues={widgetStats.issues}
|
||||
tab={tab.key}
|
||||
totalIssues={widgetStats.count}
|
||||
type="assigned"
|
||||
workspaceSlug={workspaceSlug}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
115
web/components/dashboard/widgets/created-issues.tsx
Normal file
115
web/components/dashboard/widgets/created-issues.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||
|
||||
const WIDGET_KEY = "created_issues";
|
||||
|
||||
export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
fetchWidgetStats,
|
||||
widgetDetails: allWidgetDetails,
|
||||
widgetStats: allWidgetStats,
|
||||
updateDashboardWidgetFilters,
|
||||
} = useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TCreatedIssuesWidgetResponse;
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||
|
||||
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
||||
const redirectionLink = `/${workspaceSlug}/workspace-views/created/${filterParams}`;
|
||||
|
||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col">
|
||||
<Link href={redirectionLink} className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">All issues created</h4>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
||||
onChange={(i) => {
|
||||
const selectedTab = ISSUES_TABS_LIST[i];
|
||||
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<div className="px-6">
|
||||
<TabsList />
|
||||
</div>
|
||||
<Tab.Panels as="div" className="mt-7 h-full">
|
||||
{ISSUES_TABS_LIST.map((tab) => (
|
||||
<Tab.Panel as="div" className="h-full flex flex-col">
|
||||
<WidgetIssuesList
|
||||
filter={widgetDetails.widget_filters.target_date}
|
||||
issues={widgetStats.issues}
|
||||
tab={tab.key}
|
||||
totalIssues={widgetStats.count}
|
||||
type="created"
|
||||
workspaceSlug={workspaceSlug}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { ChevronDown } from "lucide-react";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
// constants
|
||||
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: TDurationFilterOptions) => void;
|
||||
value: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||
const { onChange, value } = props;
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
|
||||
{DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
>
|
||||
{DURATION_FILTER_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.key);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
);
|
||||
};
|
||||
1
web/components/dashboard/widgets/dropdowns/index.ts
Normal file
1
web/components/dashboard/widgets/dropdowns/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./duration-filter";
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||
// constants
|
||||
import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const AssignedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter, type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">{typeDetails.title(filter)}</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image src={image} className="w-full h-full" alt="Assigned issues" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||
// constants
|
||||
import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const CreatedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter, type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = CREATED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">{typeDetails.title(filter)}</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image src={image} className="w-full h-full" alt="Created issues" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
web/components/dashboard/widgets/empty-states/index.ts
Normal file
6
web/components/dashboard/widgets/empty-states/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./issues-by-priority";
|
||||
export * from "./issues-by-state-group";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/issues-by-priority.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/issues-by-priority.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export const IssuesByPriorityEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
No assigned issues {replaceUnderscoreIfSnakeCase(filter)}.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Issues by priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/issues-by-state-group.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/issues-by-state-group.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export const IssuesByStateGroupEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
No assigned issues {replaceUnderscoreIfSnakeCase(filter)}.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Issues by state group"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/recent-activity.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/recent-activity.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const RecentActivityEmptyState: React.FC<Props> = (props) => {
|
||||
const {} = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
Feels new, go and explore our tool in depth and come back
|
||||
<br />
|
||||
to see your activity.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Issues by priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/recent-collaborators.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const RecentCollaboratorsEmptyState: React.FC<Props> = (props) => {
|
||||
const {} = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="mt-7 px-7 flex justify-between gap-16">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
People are excited to work with you, once they do you will find your frequent collaborators here.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md flex-shrink-0 self-end", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Recent collaborators"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
web/components/dashboard/widgets/index.ts
Normal file
12
web/components/dashboard/widgets/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export * from "./dropdowns";
|
||||
export * from "./empty-states";
|
||||
export * from "./issue-panels";
|
||||
export * from "./loaders";
|
||||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./issues-by-priority";
|
||||
export * from "./issues-by-state-group";
|
||||
export * from "./overview-stats";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
export * from "./recent-projects";
|
||||
3
web/components/dashboard/widgets/issue-panels/index.ts
Normal file
3
web/components/dashboard/widgets/issue-panels/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./issue-list-item";
|
||||
export * from "./issues-list";
|
||||
export * from "./tabs-list";
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import isToday from "date-fns/isToday";
|
||||
// hooks
|
||||
import { useIssueDetail, useMember, useProject } from "hooks/store";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { findTotalDaysInRange, renderFormattedDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { TIssue, TWidgetIssue } from "@plane/types";
|
||||
|
||||
export type IssueListItemProps = {
|
||||
issueId: string;
|
||||
onClick: (issue: TIssue) => void;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{issueDetails.target_date
|
||||
? isToday(new Date(issueDetails.target_date))
|
||||
? "Today"
|
||||
: renderFormattedDate(issueDetails.target_date)
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}`
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}`
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
|
||||
if (!issueDetails) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-6 flex items-center gap-3">
|
||||
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => onClick(issue)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issue.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issue.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{issue.target_date
|
||||
? isToday(new Date(issue.target_date))
|
||||
? "Today"
|
||||
: renderFormattedDate(issue.target_date)
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="text-xs flex justify-center">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => onClick(issue)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issue.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issue.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="text-xs flex justify-center">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => onClick(issue)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-3">
|
||||
<PriorityIcon priority={issue.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issue.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs flex justify-center">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
124
web/components/dashboard/widgets/issue-panels/issues-list.tsx
Normal file
124
web/components/dashboard/widgets/issue-panels/issues-list.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// components
|
||||
import {
|
||||
AssignedCompletedIssueListItem,
|
||||
AssignedIssuesEmptyState,
|
||||
AssignedOverdueIssueListItem,
|
||||
AssignedUpcomingIssueListItem,
|
||||
CreatedCompletedIssueListItem,
|
||||
CreatedIssuesEmptyState,
|
||||
CreatedOverdueIssueListItem,
|
||||
CreatedUpcomingIssueListItem,
|
||||
IssueListItemProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { Loader, getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssue, TIssuesListTypes } from "@plane/types";
|
||||
|
||||
export type WidgetIssuesListProps = {
|
||||
filter: TDurationFilterOptions | undefined;
|
||||
isLoading: boolean;
|
||||
issues: TIssue[];
|
||||
tab: TIssuesListTypes;
|
||||
totalIssues: number;
|
||||
type: "assigned" | "created";
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
const { filter, isLoading, issues, tab, totalIssues, type, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
const filterParams = getRedirectionFilters(tab);
|
||||
|
||||
const ISSUE_LIST_ITEM: {
|
||||
[key in string]: {
|
||||
[key in TIssuesListTypes]: React.FC<IssueListItemProps>;
|
||||
};
|
||||
} = {
|
||||
assigned: {
|
||||
upcoming: AssignedUpcomingIssueListItem,
|
||||
overdue: AssignedOverdueIssueListItem,
|
||||
completed: AssignedCompletedIssueListItem,
|
||||
},
|
||||
created: {
|
||||
upcoming: CreatedUpcomingIssueListItem,
|
||||
overdue: CreatedOverdueIssueListItem,
|
||||
completed: CreatedCompletedIssueListItem,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<Loader className="mx-6 mt-2 space-y-4">
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
</Loader>
|
||||
) : issues.length > 0 ? (
|
||||
<>
|
||||
<div className="mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
|
||||
<h6
|
||||
className={cn("pl-1 flex items-center gap-1 col-span-4", {
|
||||
"col-span-6": type === "assigned" && tab === "completed",
|
||||
"col-span-5": type === "created" && tab === "completed",
|
||||
})}
|
||||
>
|
||||
Issues
|
||||
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium py-1 px-1.5 rounded-xl h-4 min-w-6 flex items-center text-center justify-center">
|
||||
{totalIssues}
|
||||
</span>
|
||||
</h6>
|
||||
{tab === "upcoming" && <h6 className="text-center">Due date</h6>}
|
||||
{tab === "overdue" && <h6 className="text-center">Due by</h6>}
|
||||
{type === "assigned" && tab !== "completed" && <h6 className="text-center">Blocked by</h6>}
|
||||
{type === "created" && <h6 className="text-center">Assigned to</h6>}
|
||||
</div>
|
||||
<div className="px-4 pb-3 mt-2">
|
||||
{issues.map((issue) => {
|
||||
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
|
||||
|
||||
if (!IssueListItem) return null;
|
||||
|
||||
return (
|
||||
<IssueListItem
|
||||
key={issue.id}
|
||||
issueId={issue.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
onClick={handleIssuePeekOverview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full grid items-end">
|
||||
{type === "assigned" && <AssignedIssuesEmptyState filter={filter ?? "this_week"} type={tab} />}
|
||||
{type === "created" && <CreatedIssuesEmptyState filter={filter ?? "this_week"} type={tab} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalIssues > issues.length && (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
||||
className={cn(getButtonStyling("accent-primary", "sm"), "w-min my-3 mx-auto py-1 px-2 text-xs")}
|
||||
>
|
||||
View all issues
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
26
web/components/dashboard/widgets/issue-panels/tabs-list.tsx
Normal file
26
web/components/dashboard/widgets/issue-panels/tabs-list.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Tab } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// constants
|
||||
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||
|
||||
export const TabsList = () => (
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="border-[0.5px] border-custom-border-200 rounded grid grid-cols-3 bg-custom-background-80"
|
||||
>
|
||||
{ISSUES_TABS_LIST.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
cn("font-semibold text-xs rounded py-1.5 focus:outline-none", {
|
||||
"bg-custom-background-100 text-custom-text-300 shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selected,
|
||||
"text-custom-text-400": !selected,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
);
|
||||
208
web/components/dashboard/widgets/issues-by-priority.tsx
Normal file
208
web/components/dashboard/widgets/issues-by-priority.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import { MarimekkoGraph } from "components/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesByPriorityEmptyState,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { getCustomDates } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||
import { ISSUE_PRIORITIES } from "constants/issue";
|
||||
|
||||
const TEXT_COLORS = {
|
||||
urgent: "#F4A9AA",
|
||||
high: "#AB4800",
|
||||
medium: "#AB6400",
|
||||
low: "#1F2D5C",
|
||||
none: "#60646C",
|
||||
};
|
||||
|
||||
const CustomBar = (props: any) => {
|
||||
const { bar, workspaceSlug } = props;
|
||||
// states
|
||||
const [isMouseOver, setIsMouseOver] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/workspace-views/assigned?priority=${bar?.id}`}>
|
||||
<g
|
||||
transform={`translate(${bar?.x},${bar?.y})`}
|
||||
onMouseEnter={() => setIsMouseOver(true)}
|
||||
onMouseLeave={() => setIsMouseOver(false)}
|
||||
>
|
||||
<rect
|
||||
x={0}
|
||||
y={isMouseOver ? -6 : 0}
|
||||
width={bar?.width}
|
||||
height={isMouseOver ? bar?.height + 6 : bar?.height}
|
||||
fill={bar?.fill}
|
||||
stroke={bar?.borderColor}
|
||||
strokeWidth={bar?.borderWidth}
|
||||
rx={4}
|
||||
ry={4}
|
||||
className="duration-300"
|
||||
/>
|
||||
<text
|
||||
x={-bar?.height + 10}
|
||||
y={18}
|
||||
fill={TEXT_COLORS[bar?.id as keyof typeof TEXT_COLORS]}
|
||||
className="capitalize font-medium text-lg -rotate-90"
|
||||
dominantBaseline="text-bottom"
|
||||
>
|
||||
{bar?.id}
|
||||
</text>
|
||||
</g>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "issues_by_priority";
|
||||
|
||||
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
fetchWidgetStats,
|
||||
widgetDetails: allWidgetDetails,
|
||||
widgetStats: allWidgetStats,
|
||||
updateDashboardWidgetFilters,
|
||||
} = useDashboard();
|
||||
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TIssuesByPriorityWidgetResponse[];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
|
||||
const chartData = widgetStats
|
||||
.filter((i) => i.count !== 0)
|
||||
.map((item) => ({
|
||||
priority: item?.priority,
|
||||
percentage: (item?.count / totalCount) * 100,
|
||||
urgent: item?.priority === "urgent" ? 1 : 0,
|
||||
high: item?.priority === "high" ? 1 : 0,
|
||||
medium: item?.priority === "medium" ? 1 : 0,
|
||||
low: item?.priority === "low" ? 1 : 0,
|
||||
none: item?.priority === "none" ? 1 : 0,
|
||||
}));
|
||||
|
||||
const CustomBarsLayer = (props: any) => {
|
||||
const { bars } = props;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{bars
|
||||
?.filter((b: any) => b?.value === 1) // render only bars with value 1
|
||||
.map((bar: any) => (
|
||||
<CustomBar key={bar?.key} bar={bar} workspaceSlug={workspaceSlug} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/assigned`}
|
||||
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">Priority of assigned issues</h4>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{totalCount > 0 ? (
|
||||
<div className="flex items-center px-11 h-full">
|
||||
<div className="w-full -mt-[11px]">
|
||||
<MarimekkoGraph
|
||||
data={chartData}
|
||||
id="priority"
|
||||
value="percentage"
|
||||
dimensions={ISSUE_PRIORITIES.map((p) => ({
|
||||
id: p.key,
|
||||
value: p.key,
|
||||
}))}
|
||||
axisBottom={null}
|
||||
axisLeft={null}
|
||||
height="119px"
|
||||
margin={{
|
||||
top: 11,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
}}
|
||||
defs={PRIORITY_GRAPH_GRADIENTS}
|
||||
fill={ISSUE_PRIORITIES.map((p) => ({
|
||||
match: {
|
||||
id: p.key,
|
||||
},
|
||||
id: `gradient${p.title}`,
|
||||
}))}
|
||||
tooltip={() => <></>}
|
||||
enableGridX={false}
|
||||
enableGridY={false}
|
||||
layers={[CustomBarsLayer]}
|
||||
/>
|
||||
<div className="flex items-center gap-1 w-full mt-3 text-sm font-semibold text-custom-text-300">
|
||||
{chartData.map((item) => (
|
||||
<p
|
||||
key={item.priority}
|
||||
className="flex items-center gap-1 flex-shrink-0"
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
}}
|
||||
>
|
||||
<PriorityIcon priority={item.priority} withContainer />
|
||||
{item.percentage.toFixed(0)}%
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full grid items-end">
|
||||
<IssuesByPriorityEmptyState filter={widgetDetails.widget_filters.target_date ?? "this_week"} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
188
web/components/dashboard/widgets/issues-by-state-group.tsx
Normal file
188
web/components/dashboard/widgets/issues-by-state-group.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import { PieGraph } from "components/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesByStateGroupEmptyState,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { getCustomDates } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
|
||||
// constants
|
||||
import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||
import { STATE_GROUPS } from "constants/state";
|
||||
|
||||
const WIDGET_KEY = "issues_by_state_groups";
|
||||
|
||||
export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [activeStateGroup, setActiveStateGroup] = useState<TStateGroups>("started");
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const {
|
||||
fetchWidgetStats,
|
||||
widgetDetails: allWidgetDetails,
|
||||
widgetStats: allWidgetStats,
|
||||
updateDashboardWidgetFilters,
|
||||
} = useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[
|
||||
WIDGET_KEY
|
||||
] as TIssuesByStateGroupsWidgetResponse[];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
const totalCount = widgetStats?.reduce((acc, item) => acc + item?.count, 0);
|
||||
const chartData = widgetStats?.map((item) => ({
|
||||
color: STATE_GROUP_GRAPH_COLORS[item?.state as keyof typeof STATE_GROUP_GRAPH_COLORS],
|
||||
id: item?.state,
|
||||
label: item?.state,
|
||||
value: (item?.count / totalCount) * 100,
|
||||
}));
|
||||
|
||||
const CenteredMetric = ({ dataWithArc, centerX, centerY }: any) => {
|
||||
const data = dataWithArc?.find((datum: any) => datum?.id === activeStateGroup);
|
||||
const percentage = chartData?.find((item) => item.id === activeStateGroup)?.value?.toFixed(0);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY - 8}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className="text-3xl font-bold"
|
||||
style={{
|
||||
fill: data?.color,
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</text>
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY + 20}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className="text-sm font-medium fill-custom-text-300 capitalize"
|
||||
>
|
||||
{data?.id}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/workspace-views/assigned`}
|
||||
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">State of assigned issues</h4>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{totalCount > 0 ? (
|
||||
<div className="flex items-center pl-20 md:pl-11 lg:pl-14 pr-11 mt-11">
|
||||
<div className="flex md:flex-col lg:flex-row items-center gap-x-10 gap-y-8 w-full">
|
||||
<div className="w-full flex justify-center">
|
||||
<PieGraph
|
||||
data={chartData}
|
||||
height="220px"
|
||||
width="220px"
|
||||
innerRadius={0.6}
|
||||
cornerRadius={5}
|
||||
colors={(datum) => datum.data.color}
|
||||
padAngle={1}
|
||||
enableArcLinkLabels={false}
|
||||
enableArcLabels={false}
|
||||
activeOuterRadiusOffset={5}
|
||||
tooltip={() => <></>}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 5,
|
||||
bottom: 0,
|
||||
left: 5,
|
||||
}}
|
||||
defs={STATE_GROUP_GRAPH_GRADIENTS}
|
||||
fill={Object.values(STATE_GROUPS).map((p) => ({
|
||||
match: {
|
||||
id: p.key,
|
||||
},
|
||||
id: `gradient${p.label}`,
|
||||
}))}
|
||||
onClick={(datum, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/${workspaceSlug}/workspace-views/assigned/?state_group=${datum.id}`);
|
||||
}}
|
||||
onMouseEnter={(datum) => setActiveStateGroup(datum.id as TStateGroups)}
|
||||
layers={["arcs", CenteredMetric]}
|
||||
/>
|
||||
</div>
|
||||
<div className="justify-self-end space-y-6 w-min whitespace-nowrap">
|
||||
{chartData.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2.5 w-24">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-custom-text-300 text-sm font-medium capitalize">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-custom-text-400 text-sm">{item.value.toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full grid items-end">
|
||||
<IssuesByStateGroupEmptyState filter={widgetDetails.widget_filters.target_date ?? "this_week"} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
22
web/components/dashboard/widgets/loaders/assigned-issues.tsx
Normal file
22
web/components/dashboard/widgets/loaders/assigned-issues.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const AssignedIssuesWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 p-6 rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<Loader.Item height="17px" width="10%" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-7">
|
||||
<Loader.Item height="29px" />
|
||||
<Loader.Item height="17px" width="10%" />
|
||||
</div>
|
||||
<div className="mt-11 space-y-10">
|
||||
<Loader.Item height="11px" width="35%" />
|
||||
<Loader.Item height="11px" width="45%" />
|
||||
<Loader.Item height="11px" width="55%" />
|
||||
<Loader.Item height="11px" width="40%" />
|
||||
<Loader.Item height="11px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
1
web/components/dashboard/widgets/loaders/index.ts
Normal file
1
web/components/dashboard/widgets/loaders/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./loader";
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByPriorityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center gap-1 h-full">
|
||||
<Loader.Item height="119px" width="14%" />
|
||||
<Loader.Item height="119px" width="26%" />
|
||||
<Loader.Item height="119px" width="36%" />
|
||||
<Loader.Item height="119px" width="18%" />
|
||||
<Loader.Item height="119px" width="6%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByStateGroupWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center justify-between gap-32 mt-12 pl-6">
|
||||
<div className="w-1/2 grid place-items-center">
|
||||
<div className="rounded-full overflow-hidden relative flex-shrink-0 h-[184px] w-[184px]">
|
||||
<Loader.Item height="184px" width="184px" />
|
||||
<div className="absolute h-[100px] w-[100px] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-custom-background-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-7 flex-shrink-0">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Loader.Item key={index} height="11px" width="100%" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
31
web/components/dashboard/widgets/loaders/loader.tsx
Normal file
31
web/components/dashboard/widgets/loaders/loader.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// components
|
||||
import { AssignedIssuesWidgetLoader } from "./assigned-issues";
|
||||
import { IssuesByPriorityWidgetLoader } from "./issues-by-priority";
|
||||
import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group";
|
||||
import { OverviewStatsWidgetLoader } from "./overview-stats";
|
||||
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||
import { RecentProjectsWidgetLoader } from "./recent-projects";
|
||||
import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators";
|
||||
// types
|
||||
import { TWidgetKeys } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
widgetKey: TWidgetKeys;
|
||||
};
|
||||
|
||||
export const WidgetLoader: React.FC<Props> = (props) => {
|
||||
const { widgetKey } = props;
|
||||
|
||||
const loaders = {
|
||||
overview_stats: <OverviewStatsWidgetLoader />,
|
||||
assigned_issues: <AssignedIssuesWidgetLoader />,
|
||||
created_issues: <AssignedIssuesWidgetLoader />,
|
||||
issues_by_state_groups: <IssuesByStateGroupWidgetLoader />,
|
||||
issues_by_priority: <IssuesByPriorityWidgetLoader />,
|
||||
recent_activity: <RecentActivityWidgetLoader />,
|
||||
recent_projects: <RecentProjectsWidgetLoader />,
|
||||
recent_collaborators: <RecentCollaboratorsWidgetLoader />,
|
||||
};
|
||||
|
||||
return loaders[widgetKey];
|
||||
};
|
||||
13
web/components/dashboard/widgets/loaders/overview-stats.tsx
Normal file
13
web/components/dashboard/widgets/loaders/overview-stats.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const OverviewStatsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl py-6 grid grid-cols-4 gap-36 px-12">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="space-y-3">
|
||||
<Loader.Item height="11px" width="50%" />
|
||||
<Loader.Item height="15px" />
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
19
web/components/dashboard/widgets/loaders/recent-activity.tsx
Normal file
19
web/components/dashboard/widgets/loaders/recent-activity.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentActivityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={index} className="flex items-start gap-3.5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="16px" width="16px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
<Loader.Item height="11px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentCollaboratorsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-9">
|
||||
<Loader.Item height="17px" width="20%" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="space-y-11 flex flex-col items-center">
|
||||
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
|
||||
<Loader.Item height="69px" width="69px" />
|
||||
</div>
|
||||
<Loader.Item height="11px" width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
19
web/components/dashboard/widgets/loaders/recent-projects.tsx
Normal file
19
web/components/dashboard/widgets/loaders/recent-projects.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentProjectsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="60px" width="60px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="17px" width="42%" />
|
||||
<Loader.Item height="23px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
93
web/components/dashboard/widgets/overview-stats.tsx
Normal file
93
web/components/dashboard/widgets/overview-stats.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import { WidgetLoader } from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TOverviewStatsWidgetResponse } from "@plane/types";
|
||||
|
||||
export type WidgetProps = {
|
||||
dashboardId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "overview_stats";
|
||||
|
||||
export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TOverviewStatsWidgetResponse;
|
||||
|
||||
const today = renderFormattedPayloadDate(new Date());
|
||||
const STATS_LIST = [
|
||||
{
|
||||
key: "assigned",
|
||||
title: "Issues assigned",
|
||||
count: widgetStats?.assigned_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned`,
|
||||
},
|
||||
{
|
||||
key: "overdue",
|
||||
title: "Issues overdue",
|
||||
count: widgetStats?.pending_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`,
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
title: "Issues created",
|
||||
count: widgetStats?.created_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/created`,
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
title: "Issues completed",
|
||||
count: widgetStats?.completed_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned?state_group=completed`,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full grid grid-cols-4 p-0.5 hover:shadow-custom-shadow-4xl duration-300">
|
||||
{STATS_LIST.map((stat, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === STATS_LIST.length - 1;
|
||||
const isMiddle = !isFirst && !isLast;
|
||||
|
||||
return (
|
||||
<div key={stat.key} className="flex relative">
|
||||
{!isLast && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 h-3/5 w-[0.5px] bg-custom-border-200" />
|
||||
)}
|
||||
<Link
|
||||
href={stat.link}
|
||||
className={cn(`py-4 hover:bg-custom-background-80 duration-300 rounded-[10px] w-full break-words`, {
|
||||
"pl-11 pr-[4.725rem] mr-0.5": isFirst,
|
||||
"px-[4.725rem] mx-0.5": isMiddle,
|
||||
"px-[4.725rem] ml-0.5": isLast,
|
||||
})}
|
||||
>
|
||||
<h5 className="font-semibold text-xl">{stat.count}</h5>
|
||||
<p className="text-custom-text-300">{stat.title}</p>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
105
web/components/dashboard/widgets/recent-activity.tsx
Normal file
105
web/components/dashboard/widgets/recent-activity.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { History } from "lucide-react";
|
||||
// hooks
|
||||
import { useDashboard, useUser } from "hooks/store";
|
||||
// components
|
||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { TRecentActivityWidgetResponse } from "@plane/types";
|
||||
|
||||
const WIDGET_KEY = "recent_activity";
|
||||
|
||||
export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { currentUser } = useUser();
|
||||
// derived values
|
||||
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentActivityWidgetResponse[];
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/profile/activity"
|
||||
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-7">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">My activity</h4>
|
||||
</div>
|
||||
{widgetStats.length > 0 ? (
|
||||
<div className="space-y-6 mt-4 mx-7">
|
||||
{widgetStats.map((activity) => (
|
||||
<div key={activity.id} className="flex gap-5">
|
||||
<div className="flex-shrink-0">
|
||||
{activity.field ? (
|
||||
activity.new_value === "restore" ? (
|
||||
<History className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
) : (
|
||||
<div className="h-6 w-6 flex justify-center">
|
||||
<ActivityIcon activity={activity} />
|
||||
</div>
|
||||
)
|
||||
) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||
<Avatar
|
||||
src={activity.actor_detail.avatar}
|
||||
name={activity.actor_detail.display_name}
|
||||
size={24}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white">
|
||||
{activity.actor_detail.is_bot
|
||||
? activity.actor_detail.first_name.charAt(0)
|
||||
: activity.actor_detail.display_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-1 break-words">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail.display_name}{" "}
|
||||
</span>
|
||||
{activity.field ? (
|
||||
<ActivityMessage activity={activity} showIssue />
|
||||
) : (
|
||||
<span>
|
||||
created this{" "}
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-200 hover:underline"
|
||||
>
|
||||
Issue.
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-custom-text-200">{calculateTimeAgo(activity.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full grid items-end">
|
||||
<RecentActivityEmptyState />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
93
web/components/dashboard/widgets/recent-collaborators.tsx
Normal file
93
web/components/dashboard/widgets/recent-collaborators.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useDashboard, useMember, useUser } from "hooks/store";
|
||||
// components
|
||||
import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// types
|
||||
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
|
||||
|
||||
type CollaboratorListItemProps = {
|
||||
issueCount: number;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "recent_collaborators";
|
||||
|
||||
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
|
||||
const { issueCount, userId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const userDetails = getUserDetails(userId);
|
||||
const isCurrentUser = userId === currentUser?.id;
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
|
||||
<div className="flex justify-center">
|
||||
<Avatar
|
||||
src={userDetails.avatar}
|
||||
name={isCurrentUser ? "You" : userDetails.display_name}
|
||||
size={69}
|
||||
className="!text-3xl !font-medium"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<h6 className="mt-6 text-xs font-semibold group-hover:underline truncate">
|
||||
{isCurrentUser ? "You" : userDetails?.display_name}
|
||||
</h6>
|
||||
<p className="text-sm mt-2">
|
||||
{issueCount} active issue{issueCount > 1 ? "s" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[
|
||||
WIDGET_KEY
|
||||
] as TRecentCollaboratorsWidgetResponse[];
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300">
|
||||
<div className="flex items-center justify-between gap-2 px-7 pt-6">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
|
||||
</div>
|
||||
{widgetStats.length > 1 ? (
|
||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||
{widgetStats.map((user) => (
|
||||
<CollaboratorListItem
|
||||
key={user.user_id}
|
||||
issueCount={user.active_issue_count}
|
||||
userId={user.user_id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full grid items-end">
|
||||
<RecentCollaboratorsEmptyState />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
125
web/components/dashboard/widgets/recent-projects.tsx
Normal file
125
web/components/dashboard/widgets/recent-projects.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useDashboard, useProject, useUser } from "hooks/store";
|
||||
// components
|
||||
import { WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
import { TRecentProjectsWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard";
|
||||
|
||||
const WIDGET_KEY = "recent_projects";
|
||||
|
||||
type ProjectListItemProps = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
|
||||
const { projectId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`} className="group flex items-center gap-8">
|
||||
<div
|
||||
className={`h-[3.375rem] w-[3.375rem] grid place-items-center rounded border border-transparent flex-shrink-0 ${randomBgColor}`}
|
||||
>
|
||||
{projectDetails.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(projectDetails.emoji)}
|
||||
</span>
|
||||
) : projectDetails.icon_prop ? (
|
||||
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.icon_prop)}</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{projectDetails.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow truncate">
|
||||
<h6 className="text-sm text-custom-text-300 font-medium group-hover:underline group-hover:text-custom-text-100 truncate">
|
||||
{projectDetails.name}
|
||||
</h6>
|
||||
<div className="mt-2">
|
||||
<AvatarGroup>
|
||||
{projectDetails.members?.map((member) => (
|
||||
<Avatar src={member.member__avatar} name={member.member__display_name} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: { toggleCreateProjectModal },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentProjectsWidgetResponse;
|
||||
const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-7">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">My projects</h4>
|
||||
</div>
|
||||
<div className="space-y-8 mt-4 mx-7">
|
||||
{canCreateProject && (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex items-center gap-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="h-[3.375rem] w-[3.375rem] bg-custom-primary-100/20 text-custom-primary-100 grid place-items-center rounded border border-dashed border-custom-primary-60 flex-shrink-0">
|
||||
<Plus className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm text-custom-text-300 font-medium group-hover:underline group-hover:text-custom-text-100">
|
||||
Create new project
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
{widgetStats.map((projectId) => (
|
||||
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue