style: new custom analytics ui (#1055)

This commit is contained in:
Aaryan Khandelwal 2023-05-16 10:41:37 +05:30 committed by GitHub
parent 8c707cc544
commit c6d78b5e6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 797 additions and 496 deletions

View File

@ -62,7 +62,7 @@ export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClos
x_axis: "priority", x_axis: "priority",
y_axis: "issue_count", y_axis: "issue_count",
...params, ...params,
project: params?.project ? [params.project] : [], project: params?.project ?? [],
}, },
}; };

View File

@ -1,144 +1,130 @@
import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import { mutate } from "swr";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { Control, UseFormSetValue } from "react-hook-form";
// services // hooks
import analyticsService from "services/analytics.service"; import useProjects from "hooks/use-projects";
// components // components
import { import {
AnalyticsGraph, AnalyticsGraph,
AnalyticsSelectBar,
AnalyticsSidebar, AnalyticsSidebar,
AnalyticsTable, AnalyticsTable,
CreateUpdateAnalyticsModal,
} from "components/analytics"; } from "components/analytics";
// ui // ui
import { Loader, PrimaryButton } from "components/ui"; import { Loader, PrimaryButton } from "components/ui";
// helpers // helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper"; import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types // types
import { IAnalyticsParams } from "types"; import { IAnalyticsParams, IAnalyticsResponse } from "types";
// fetch-keys // fetch-keys
import { ANALYTICS } from "constants/fetch-keys"; import { ANALYTICS } from "constants/fetch-keys";
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
type Props = { type Props = {
isProjectLevel?: boolean; analytics: IAnalyticsResponse | undefined;
fullScreen?: boolean; analyticsError: any;
params: IAnalyticsParams;
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
fullScreen: boolean;
}; };
export const CustomAnalytics: React.FC<Props> = ({ isProjectLevel = false, fullScreen = true }) => { export const CustomAnalytics: React.FC<Props> = ({
const [saveAnalyticsModal, setSaveAnalyticsModal] = useState(false); analytics,
analyticsError,
params,
control,
setValue,
fullScreen,
}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues }); const isProjectLevel = projectId ? true : false;
const params: IAnalyticsParams = { const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: isProjectLevel ? projectId?.toString() : watch("project"),
cycle: isProjectLevel && cycleId ? cycleId.toString() : null,
module: isProjectLevel && moduleId ? moduleId.toString() : null,
};
const {
data: analytics,
error: analyticsError,
mutate: mutateAnalytics,
} = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const yAxisKey = params.y_axis === "issue_count" ? "count" : "effort";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params); const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
const { projects } = useProjects();
return ( return (
<> <div
<CreateUpdateAnalyticsModal className={`overflow-hidden flex flex-col-reverse ${
isOpen={saveAnalyticsModal} fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
handleClose={() => setSaveAnalyticsModal(false)} }`}
params={params} >
/> <div className="col-span-3 flex flex-col h-full overflow-hidden">
<div <AnalyticsSelectBar
className={`overflow-y-auto ${ control={control}
fullScreen ? "grid grid-cols-4 h-full" : "flex flex-col-reverse" setValue={setValue}
}`} projects={projects}
> params={params}
<div className="col-span-3"> fullScreen={fullScreen}
{!analyticsError ? ( isProjectLevel={isProjectLevel}
analytics ? ( />
analytics.total > 0 ? ( {!analyticsError ? (
<> analytics ? (
<AnalyticsGraph analytics.total > 0 ? (
analytics={analytics} <div className="h-full overflow-y-auto">
barGraphData={barGraphData} <AnalyticsGraph
params={params} analytics={analytics}
yAxisKey={yAxisKey} barGraphData={barGraphData}
fullScreen={fullScreen} params={params}
/> yAxisKey={yAxisKey}
<AnalyticsTable fullScreen={fullScreen}
analytics={analytics} />
barGraphData={barGraphData} <AnalyticsTable
params={params} analytics={analytics}
yAxisKey={yAxisKey} barGraphData={barGraphData}
/> params={params}
</> yAxisKey={yAxisKey}
) : ( />
<div className="grid h-full place-items-center p-5"> </div>
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">
No matching issues found. Try changing the parameters.
</p>
</div>
</div>
)
) : ( ) : (
<Loader className="space-y-6 p-5"> <div className="grid h-full place-items-center p-5">
<Loader.Item height="300px" /> <div className="space-y-4 text-brand-secondary">
<Loader className="space-y-4"> <p className="text-sm">No matching issues found. Try changing the parameters.</p>
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={() => mutateAnalytics()}>Refresh</PrimaryButton>
</div> </div>
</div> </div>
)
) : (
<Loader className="space-y-6 p-5">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</PrimaryButton>
</div>
</div> </div>
)} </div>
</div> )}
<div className={fullScreen ? "h-full" : ""}>
<AnalyticsSidebar
analytics={analytics}
params={params}
control={control}
setValue={setValue}
setSaveAnalyticsModal={setSaveAnalyticsModal}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
</div>
</div> </div>
</> <AnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
</div>
); );
}; };

View File

@ -18,11 +18,11 @@ export const CustomTooltip: React.FC<Props> = ({ datum, params }) => {
else tooltipValue = datum.id; else tooltipValue = datum.id;
} else { } else {
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue; if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Issue count" : "Effort"; else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
} }
return ( return (
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-base p-2 text-xs"> <div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<span <span
className="h-3 w-3 rounded" className="h-3 w-3 rounded"
style={{ style={{

View File

@ -18,7 +18,7 @@ type Props = {
xAxisKeys: string[]; xAxisKeys: string[];
}; };
params: IAnalyticsParams; params: IAnalyticsParams;
yAxisKey: "effort" | "count"; yAxisKey: "count" | "estimate";
fullScreen: boolean; fullScreen: boolean;
}; };
@ -70,6 +70,7 @@ export const AnalyticsGraph: React.FC<Props> = ({
height={fullScreen ? "400px" : "300px"} height={fullScreen ? "400px" : "300px"}
margin={{ right: 20, bottom: longestXAxisLabel.length * 5 + 20 }} margin={{ right: 20, bottom: longestXAxisLabel.length * 5 + 20 }}
theme={{ theme={{
background: "rgb(var(--color-bg-base))",
axis: {}, axis: {},
}} }}
/> />

View File

@ -1,5 +1,6 @@
export * from "./graph"; export * from "./graph";
export * from "./create-update-analytics-modal"; export * from "./create-update-analytics-modal";
export * from "./custom-analytics"; export * from "./custom-analytics";
export * from "./select-bar";
export * from "./sidebar"; export * from "./sidebar";
export * from "./table"; export * from "./table";

View File

@ -0,0 +1,80 @@
// react-hook-form
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// components
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
// types
import { IAnalyticsParams, IProject } from "types";
type Props = {
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
projects: IProject[];
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
export const AnalyticsSelectBar: React.FC<Props> = ({
control,
setValue,
projects,
params,
fullScreen,
isProjectLevel,
}) => (
<div
className={`grid items-center gap-4 p-5 pb-0.5 ${
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
} ${fullScreen ? "lg:grid-cols-4" : ""}`}
>
{!isProjectLevel && (
<div>
<h6 className="text-xs text-brand-secondary">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject value={value} onChange={onChange} projects={projects} />
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectYAxis value={value} onChange={onChange} />
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectXAxis
value={value}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
/>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} />
)}
/>
</div>
</div>
);

View File

@ -1,51 +1,82 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-hook-form
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// services // services
import analyticsService from "services/analytics.service"; import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
// hooks // hooks
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { CustomMenu, CustomSelect, PrimaryButton } from "components/ui"; import { PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { ArrowPathIcon, ArrowUpTrayIcon } from "@heroicons/react/24/outline"; import { ArrowDownTrayIcon, ArrowPathIcon, UserGroupIcon } from "@heroicons/react/24/outline";
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types // types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData } from "types"; import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
// fetch-keys // fetch-keys
import { ANALYTICS } from "constants/fetch-keys"; import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants // constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics"; import { NETWORK_CHOICES } from "constants/project";
type Props = { type Props = {
analytics: IAnalyticsResponse | undefined; analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams; params: IAnalyticsParams;
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
setSaveAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
fullScreen: boolean; fullScreen: boolean;
isProjectLevel?: boolean; isProjectLevel: boolean;
}; };
export const AnalyticsSidebar: React.FC<Props> = ({ export const AnalyticsSidebar: React.FC<Props> = ({
analytics, analytics,
params, params,
control,
setValue,
setSaveAnalyticsModal,
fullScreen, fullScreen,
isProjectLevel = false, isProjectLevel = false,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { projects } = useProjects(); const { projects } = useProjects();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR(
workspaceSlug && projectId && !(cycleId || moduleId)
? PROJECT_DETAILS(projectId.toString())
: null,
workspaceSlug && projectId && !(cycleId || moduleId)
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: cycleDetails } = useSWR(
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleDetails(
workspaceSlug.toString(),
projectId.toString(),
cycleId.toString()
)
: null
);
const { data: moduleDetails } = useSWR(
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleDetails(
workspaceSlug.toString(),
projectId.toString(),
moduleId.toString()
)
: null
);
const exportAnalytics = () => { const exportAnalytics = () => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -55,7 +86,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
}; };
if (params.segment) data.segment = params.segment; if (params.segment) data.segment = params.segment;
if (params.project) data.project = [params.project]; if (params.project) data.project = params.project;
analyticsService analyticsService
.exportAnalytics(workspaceSlug.toString(), data) .exportAnalytics(workspaceSlug.toString(), data)
@ -77,155 +108,180 @@ export const AnalyticsSidebar: React.FC<Props> = ({
return ( return (
<div <div
className={`gap-4 p-5 ${ className={`p-5 pb-0 flex flex-col space-y-2 md:space-y-4 overflow-hidden ${
fullScreen ? "border-l border-brand-base bg-brand-sidebar h-full" : "" fullScreen
? "pb-5 border-l border-brand-base md:h-full md:pb-5 md:border-l md:border-brand-base"
: ""
}`} }`}
> >
<div className={`sticky top-5 ${fullScreen ? "space-y-4" : "space-y-2"}`}> <div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center justify-between gap-2 flex-shrink-0"> <div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
<h5 className="text-lg font-medium"> <LayerDiagonalIcon height={14} width={14} />
{analytics?.total ?? 0}{" "} {analytics ? analytics.total : "..."} Issues
<span className="text-xs font-normal text-brand-secondary">issues</span>
</h5>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className="flex items-center gap-2">
<ArrowPathIcon className="h-3 w-3" />
Refresh
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={exportAnalytics}>
<div className="flex items-center gap-2">
<ArrowUpTrayIcon className="h-3 w-3" />
Export analytics as CSV
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
<div className={`${fullScreen ? "space-y-4" : "grid items-center gap-4 grid-cols-3"}`}> </div>
{isProjectLevel === false && ( <div className="h-full overflow-hidden">
<div> {fullScreen ? (
<h6 className="text-xs text-brand-secondary">Project</h6> <>
<Controller {!isProjectLevel && params.project && params.project.length > 0 && (
name="project" <div className="hidden h-full overflow-hidden md:flex md:flex-col">
control={control} <h4 className="font-medium">Selected Projects</h4>
render={({ field: { value, onChange } }) => ( <div className="space-y-6 mt-4 h-full overflow-y-auto">
<CustomSelect {params.project.map((projectId) => {
value={value} const project: IProject = projects.find((p) => p.id === projectId);
label={projects.find((p) => p.id === value)?.name ?? "All projects"}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>All projects</CustomSelect.Option>
{projects.map((project) => (
<CustomSelect.Option key={project.id} value={project.id}>
{project.name}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}
</span>
}
onChange={onChange}
width="w-full"
>
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>
}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
width="w-full"
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Segment</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
<span className="text-brand-secondary">No value</span>
)}
</span>
}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
return ( return (
<CustomSelect.Option key={item.value} value={item.value}> <div key={project.id}>
{item.label} <h5 className="text-sm flex items-center gap-1">
</CustomSelect.Option> {project.icon ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{String.fromCodePoint(parseInt(project.icon))}
</span>
) : (
<span className="grid h-8 w-8 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<span className="break-all">{project.name}</span>
</h5>
<div className="mt-4 space-y-3 pl-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
<h6>Total members</h6>
</div>
<span className="text-brand-secondary">{project.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<ContrastIcon height={16} width={16} />
<h6>Total cycles</h6>
</div>
<span className="text-brand-secondary">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
<h6>Total modules</h6>
</div>
<span className="text-brand-secondary">{project.total_modules}</span>
</div>
</div>
</div>
); );
})} })}
</CustomSelect> </div>
)} </div>
/> )}
{projectId ? (
cycleId && cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">{cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6>
<span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Start Date</h6>
<span>
{cycleDetails.start_date && cycleDetails.start_date !== ""
? renderShortDate(cycleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderShortDate(cycleDetails.end_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : moduleId && moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">{moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6>
<span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Start Date</h6>
<span>
{moduleDetails.start_date && moduleDetails.start_date !== ""
? renderShortDate(moduleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Target Date</h6>
<span>
{moduleDetails.target_date && moduleDetails.target_date !== ""
? renderShortDate(moduleDetails.target_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : (
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails?.icon ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{String.fromCodePoint(parseInt(projectDetails.icon))}
</span>
) : (
<span className="grid h-8 w-8 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Network</h6>
<span>
{
NETWORK_CHOICES[
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
]
}
</span>
</div>
</div>
</div>
)
) : null}
</>
) : null}
</div>
<div className="flex items-center gap-2 flex-wrap justify-self-end">
<SecondaryButton
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className="flex items-center gap-2 -my-1">
<ArrowPathIcon className="h-3.5 w-3.5" />
Refresh
</div> </div>
</div> </SecondaryButton>
{/* <div className="flex items-center justify-end gap-2"> <PrimaryButton onClick={exportAnalytics}>
<PrimaryButton className="py-1" onClick={() => setSaveAnalyticsModal(true)}> <div className="flex items-center gap-2 -my-1">
Save analytics <ArrowDownTrayIcon className="h-3.5 w-3.5" />
</PrimaryButton> Export as CSV
</div> */} </div>
</PrimaryButton>
</div> </div>
</div> </div>
); );

View File

@ -10,7 +10,6 @@ import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
import { IAnalyticsParams, IAnalyticsResponse } from "types"; import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants // constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics"; import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
import { MONTHS_LIST } from "constants/calendar";
type Props = { type Props = {
analytics: IAnalyticsResponse; analytics: IAnalyticsResponse;
@ -19,7 +18,7 @@ type Props = {
xAxisKeys: string[]; xAxisKeys: string[];
}; };
params: IAnalyticsParams; params: IAnalyticsParams;
yAxisKey: "effort" | "count"; yAxisKey: "count" | "estimate";
}; };
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => ( export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => (
@ -27,7 +26,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle"> <div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base"> <table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
<thead className="bg-brand-base"> <thead className="bg-brand-surface-2">
<tr className="divide-x divide-brand-base text-sm text-brand-base"> <tr className="divide-x divide-brand-base text-sm text-brand-base">
<th scope="col" className="py-3 px-2.5 text-left font-medium"> <th scope="col" className="py-3 px-2.5 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label} {ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}

View File

@ -1,4 +1,4 @@
export * from "./custom-analytics"; export * from "./custom-analytics";
export * from "./scope-and-demand"; export * from "./scope-and-demand";
export * from "./select";
export * from "./project-modal"; export * from "./project-modal";
export * from "./workspace-modal";

View File

@ -1,7 +1,15 @@
import React, { Fragment, useState } from "react"; import React, { Fragment, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// components // components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics"; import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons // icons
@ -10,17 +18,47 @@ import {
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
XMarkIcon, XMarkIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}; };
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const tabsList = ["Scope and Demand", "Custom Analytics"]; const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => { export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const [fullScreen, setFullScreen] = useState(false); const [fullScreen, setFullScreen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: projectId ? [projectId.toString()] : watch("project"),
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
};
const { data: analytics, error: analyticsError } = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
}; };
@ -32,12 +70,12 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`} } ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
> >
<div <div
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-surface-1 text-left ${ className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-base text-left ${
fullScreen ? "rounded-lg border" : "border-l" fullScreen ? "rounded-lg border" : "border-l"
}`} }`}
> >
<div <div
className={`flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-sidebar p-3 text-sm ${ className={`flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-base p-3 text-sm ${
fullScreen ? "" : "py-[1.275rem]" fullScreen ? "" : "py-[1.275rem]"
}`} }`}
> >
@ -64,13 +102,13 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
</div> </div>
</div> </div>
<Tab.Group as={Fragment}> <Tab.Group as={Fragment}>
<Tab.List className="space-x-2 border-b border-brand-base px-5 py-3"> <Tab.List as="div" className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => ( {tabsList.map((tab) => (
<Tab <Tab
key={tab} key={tab}
className={({ selected }) => className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-base ${ `rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-surface-2 ${
selected ? "bg-brand-base" : "" selected ? "bg-brand-surface-2" : ""
}` }`
} }
> >
@ -80,10 +118,17 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
</Tab.List> </Tab.List>
<Tab.Panels as={Fragment}> <Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}> <Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen={fullScreen} isProjectLevel /> <ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as={Fragment}> <Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen={fullScreen} isProjectLevel /> <CustomAnalytics
analytics={analytics}
analyticsError={analyticsError}
params={params}
control={control}
setValue={setValue}
fullScreen={fullScreen}
/>
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@ -50,7 +50,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
); );
})} })}
</div> </div>
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-base p-2 text-xs"> <div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<p className="flex items-center gap-1 text-brand-secondary"> <p className="flex items-center gap-1 text-brand-secondary">
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" /> <PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
<span>Estimate Demand:</span> <span>Estimate Demand:</span>

View File

@ -3,7 +3,8 @@ import Image from "next/image";
type Props = { type Props = {
users: { users: {
avatar: string | null; avatar: string | null;
email: string | null; firstName: string;
lastName: string;
count: number; count: number;
}[]; }[];
title: string; title: string;
@ -13,8 +14,8 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<div className="p-3 border border-brand-base rounded-[10px]"> <div className="p-3 border border-brand-base rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6> <h6 className="text-base font-medium">{title}</h6>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{users.map((user) => ( {users.map((user, index) => (
<div key={user.email} className="flex items-start justify-between gap-4 text-xs"> <div key={`user-${index}`} className="flex items-start justify-between gap-4 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user && user.avatar && user.avatar !== "" ? ( {user && user.avatar && user.avatar !== "" ? (
<div className="rounded-full h-4 w-4 flex-shrink-0"> <div className="rounded-full h-4 w-4 flex-shrink-0">
@ -23,15 +24,17 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
height="100%" height="100%"
width="100%" width="100%"
className="rounded-full" className="rounded-full"
alt={user.email ?? "No assignee"} alt={user.firstName + " " + user.lastName}
/> />
</div> </div>
) : ( ) : (
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4"> <div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
{(user.email ?? "No assignee").charAt(0)} {user.firstName !== "" ? user.firstName[0] : "?"}
</div> </div>
)} )}
<span className="break-all text-brand-secondary">{user.email ?? "No assignee"}</span> <span className="break-all text-brand-secondary">
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
</span>
</div> </div>
<span className="flex-shrink-0">{user.count}</span> <span className="flex-shrink-0">{user.count}</span>
</div> </div>

View File

@ -18,16 +18,17 @@ import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
type Props = { type Props = {
fullScreen?: boolean; fullScreen?: boolean;
isProjectLevel?: boolean;
}; };
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true, isProjectLevel = true }) => { export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isProjectLevel = projectId ? true : false;
const params = isProjectLevel const params = isProjectLevel
? { ? {
project: projectId ? projectId.toString() : null, project: projectId ? [projectId.toString()] : null,
cycle: cycleId ? cycleId.toString() : null, cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null, module: moduleId ? moduleId.toString() : null,
} }
@ -55,7 +56,8 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true, isProjectLe
<AnalyticsLeaderboard <AnalyticsLeaderboard
users={defaultAnalytics.most_issue_created_user.map((user) => ({ users={defaultAnalytics.most_issue_created_user.map((user) => ({
avatar: user.created_by__avatar, avatar: user.created_by__avatar,
email: user.created_by__email, firstName: user.created_by__first_name,
lastName: user.created_by__last_name,
count: user.count, count: user.count,
}))} }))}
title="Most issues created" title="Most issues created"
@ -63,7 +65,8 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true, isProjectLe
<AnalyticsLeaderboard <AnalyticsLeaderboard
users={defaultAnalytics.most_issue_closed_user.map((user) => ({ users={defaultAnalytics.most_issue_closed_user.map((user) => ({
avatar: user.assignees__avatar, avatar: user.assignees__avatar,
email: user.assignees__email, firstName: user.assignees__first_name,
lastName: user.assignees__last_name,
count: user.count, count: user.count,
}))} }))}
title="Most issues closed" title="Most issues closed"

View File

@ -21,7 +21,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
colors={() => `#f97316`} colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)} customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
tooltip={(datum) => ( tooltip={(datum) => (
<div className="rounded-md border border-brand-base bg-brand-base p-2 text-xs"> <div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<span className="font-medium text-brand-secondary"> <span className="font-medium text-brand-secondary">
Issue count- {datum.indexValue ?? "No assignee"}:{" "} Issue count- {datum.indexValue ?? "No assignee"}:{" "}
</span> </span>
@ -51,13 +51,17 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" /> <circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff"> <text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{(`${datum.value}` ?? "No assignee").toUpperCase().charAt(0)} {datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
</text> </text>
</g> </g>
); );
}, },
}} }}
margin={{ top: 20 }} margin={{ top: 20 }}
theme={{
background: "rgb(var(--color-bg-base))",
axis: {},
}}
/> />
</div> </div>
</div> </div>

View File

@ -40,6 +40,9 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
colors={(datum) => datum.color} colors={(datum) => datum.color}
curve="monotoneX" curve="monotoneX"
margin={{ top: 20 }} margin={{ top: 20 }}
theme={{
background: "rgb(var(--color-bg-base))",
}}
enableArea enableArea
/> />
</div> </div>

View File

@ -0,0 +1,4 @@
export * from "./project";
export * from "./segment";
export * from "./x-axis";
export * from "./y-axis";

View File

@ -0,0 +1,38 @@
// ui
import { CustomSearchSelect } from "components/ui";
// types
import { IProject } from "types";
type Props = {
value: string[] | null | undefined;
onChange: (val: string[] | null) => void;
projects: IProject[];
};
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
const options = projects?.map((project) => ({
value: project.id,
query: project.name + project.identifier,
content: <>{project.name}</>,
}));
return (
<CustomSearchSelect
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
label={
value && value.length > 0
? projects
.filter((p) => value.includes(p.id))
.map((p) => p.identifier)
.join(", ")
: "All projects"
}
optionsClassName="min-w-full"
position="right"
noChevron
multiple
/>
);
};

View File

@ -0,0 +1,39 @@
// ui
import { CustomSelect } from "components/ui";
// types
import { IAnalyticsParams, TXAxisValues } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TXAxisValues | null | undefined;
onChange: () => void;
params: IAnalyticsParams;
};
export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
<span className="text-brand-secondary">No value</span>
)}
</span>
}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
);

View File

@ -0,0 +1,27 @@
// ui
import { CustomSelect } from "components/ui";
// types
import { IAnalyticsParams, TXAxisValues, TYAxisValues } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TXAxisValues;
onChange: (val: string) => void;
};
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => (
<CustomSelect
value={value}
label={<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
);

View File

@ -0,0 +1,26 @@
// ui
import { CustomSelect } from "components/ui";
// types
import { IAnalyticsParams, TYAxisValues } from "types";
// constants
import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TYAxisValues;
onChange: () => void;
};
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
<CustomSelect
value={value}
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
width="w-full"
>
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
);

View File

@ -1,70 +0,0 @@
import React, { Fragment } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsWorkspaceModal: React.FC<Props> = ({ isOpen, onClose }) => {
const handleClose = () => {
onClose();
};
return (
<>
<div
className={`absolute z-40 h-full w-full bg-brand-surface-1 p-2 ${
isOpen ? "block" : "hidden"
}`}
>
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-brand-base bg-brand-surface-1 text-left">
<div className="flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-sidebar p-3 text-sm">
<h3>Workspace Analytics</h3>
<div>
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-base ${
selected ? "bg-brand-base" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand isProjectLevel={false} />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
</>
);
};

View File

@ -1,6 +1,8 @@
export const getPriorityIcon = (priority: string | null, className?: string) => { export const getPriorityIcon = (priority: string | null, className?: string) => {
if (!className || className === "") className = "text-xs flex items-center"; if (!className || className === "") className = "text-xs flex items-center";
priority = priority?.toLowerCase() ?? null;
switch (priority) { switch (priority) {
case "urgent": case "urgent":
return <span className={`material-symbols-rounded ${className}`}>error</span>; return <span className={`material-symbols-rounded ${className}`}>error</span>;

View File

@ -6,18 +6,10 @@ import Link from "next/link";
// hooks // hooks
import useTheme from "hooks/use-theme"; import useTheme from "hooks/use-theme";
// icons // icons
import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon, SettingIcon } from "components/icons";
import { ChartBarIcon } from "@heroicons/react/24/outline"; import { ChartBarIcon } from "@heroicons/react/24/outline";
import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon, SettingIcon } from "components/icons";
type Props = { export const WorkspaceSidebarMenu = () => {
isAnalyticsModalOpen: boolean;
setAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
};
export const WorkspaceSidebarMenu: React.FC<Props> = ({
isAnalyticsModalOpen,
setAnalyticsModal,
}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -33,8 +25,7 @@ export const WorkspaceSidebarMenu: React.FC<Props> = ({
{ {
icon: ChartBarIcon, icon: ChartBarIcon,
name: "Analytics", name: "Analytics",
highlight: isAnalyticsModalOpen, href: `/${workspaceSlug}/analytics`,
onClick: () => setAnalyticsModal((prevData) => !prevData),
}, },
{ {
icon: AssignmentClipboardIcon, icon: AssignmentClipboardIcon,
@ -55,59 +46,33 @@ export const WorkspaceSidebarMenu: React.FC<Props> = ({
return ( return (
<div className="flex w-full flex-col items-start justify-start gap-2 px-3 py-1"> <div className="flex w-full flex-col items-start justify-start gap-2 px-3 py-1">
{workspaceLinks(workspaceSlug as string).map((link, index) => { {workspaceLinks(workspaceSlug as string).map((link, index) => (
if (link.href) <Link key={index} href={link.href}>
return ( <a
<Link key={index} href={link.href}> className={`${
<a (
className={`${ link.name === "Settings"
( ? router.asPath.includes(link.href)
link.name === "Settings" : router.asPath === link.href
? router.asPath.includes(link.href) )
: router.asPath === link.href ? "bg-brand-surface-2 text-brand-base"
) : "text-brand-secondary hover:bg-brand-surface-2 focus:bg-brand-surface-2"
? "bg-brand-surface-2 text-brand-base" } group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium outline-none ${
: "text-brand-secondary hover:bg-brand-surface-2 focus:bg-brand-surface-2" sidebarCollapse ? "justify-center" : ""
} group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium outline-none ${ }`}
sidebarCollapse ? "justify-center" : "" >
}`} <span className="grid h-5 w-5 flex-shrink-0 place-items-center">
> <link.icon
<span className="grid h-5 w-5 flex-shrink-0 place-items-center"> className="text-brand-secondary"
<link.icon aria-hidden="true"
className="text-brand-secondary" height="20"
aria-hidden="true" width="20"
height="20" />
width="20" </span>
/> {!sidebarCollapse && link.name}
</span> </a>
{!sidebarCollapse && link.name} </Link>
</a> ))}
</Link>
);
else
return (
<button
key={index}
type="button"
className={`group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium text-brand-secondary outline-none hover:bg-brand-surface-2 ${
sidebarCollapse ? "justify-center" : ""
} ${link.highlight ? "bg-brand-surface-2 text-brand-base" : ""}`}
onClick={() => {
if (link.onClick) link.onClick();
}}
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
<link.icon
className="text-brand-secondary"
aria-hidden="true"
height="20"
width="20"
/>
</span>
{!sidebarCollapse && link.name}
</button>
);
})}
</div> </div>
); );
}; };

View File

@ -58,8 +58,8 @@ export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
label: "Issue Count", label: "Issue Count",
}, },
{ {
value: "effort", value: "estimate",
label: "Effort", label: "Estimate",
}, },
]; ];

View File

@ -169,10 +169,10 @@ export const ESTIMATE_DETAILS = (estimateId: string) =>
// analytics // analytics
export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) => export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
`ANALYTICS${workspaceSlug.toUpperCase()}_${params.x_axis}_${params.y_axis}_${params.segment}_${ `ANALYTICS${workspaceSlug.toUpperCase()}_${params.x_axis}_${params.y_axis}_${
params.project params.segment
}`; }_${params.project?.toString()}`;
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) => export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project}_${params?.cycle}_${ `DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${
params?.module params?.cycle
}`; }_${params?.module}`;

View File

@ -1,5 +1,7 @@
// nivo // nivo
import { BarDatum } from "@nivo/bar"; import { BarDatum } from "@nivo/bar";
// helpers
import { capitalizeFirstLetter } from "helpers/string.helper";
// types // types
import { IAnalyticsData, IAnalyticsParams, IAnalyticsResponse } from "types"; import { IAnalyticsData, IAnalyticsParams, IAnalyticsResponse } from "types";
// constants // constants
@ -17,7 +19,7 @@ export const convertResponseToBarGraphData = (
const data: BarDatum[] = []; const data: BarDatum[] = [];
let xAxisKeys: string[] = []; let xAxisKeys: string[] = [];
const yAxisKey = params.y_axis === "issue_count" ? "count" : "effort"; const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
Object.keys(response).forEach((key) => { Object.keys(response).forEach((key) => {
const segments: { [key: string]: number } = {}; const segments: { [key: string]: number } = {};
@ -31,7 +33,11 @@ export const convertResponseToBarGraphData = (
}); });
data.push({ data.push({
name: DATE_KEYS.includes(params.x_axis) ? renderMonthAndYear(key) : key, name: DATE_KEYS.includes(params.x_axis)
? renderMonthAndYear(key)
: params.x_axis === "priority" || params.x_axis === "state__group"
? capitalizeFirstLetter(key)
: key,
...segments, ...segments,
}); });
} else { } else {
@ -42,6 +48,8 @@ export const convertResponseToBarGraphData = (
data.push({ data.push({
name: DATE_KEYS.includes(params.x_axis) name: DATE_KEYS.includes(params.x_axis)
? renderMonthAndYear(item.dimension) ? renderMonthAndYear(item.dimension)
: params.x_axis === "priority" || params.x_axis === "state__group"
? capitalizeFirstLetter(item.dimension ?? "None")
: item.dimension ?? "None", : item.dimension ?? "None",
[yAxisKey]: item[yAxisKey] ?? 0, [yAxisKey]: item[yAxisKey] ?? 0,
}); });
@ -64,17 +72,17 @@ export const generateBarColor = (
if (params[type] === "state__name" || params[type] === "labels__name") if (params[type] === "state__name" || params[type] === "labels__name")
color = analytics?.extras?.colors.find((c) => c.name === value)?.color; color = analytics?.extras?.colors.find((c) => c.name === value)?.color;
if (params[type] === "state__group") color = STATE_GROUP_COLORS[value]; if (params[type] === "state__group") color = STATE_GROUP_COLORS[value.toLowerCase()];
if (params[type] === "priority") if (params[type] === "priority")
color = color =
value === "urgent" value === "Urgent"
? "#ef4444" ? "#ef4444"
: value === "high" : value === "High"
? "#f97316" ? "#f97316"
: value === "medium" : value === "Medium"
? "#eab308" ? "#eab308"
: value === "low" : value === "Low"
? "#22c55e" ? "#22c55e"
: "#ced4da"; : "#ced4da";

View File

@ -11,16 +11,9 @@ import { ProjectSidebarList } from "components/project";
export interface SidebarProps { export interface SidebarProps {
toggleSidebar: boolean; toggleSidebar: boolean;
setToggleSidebar: React.Dispatch<React.SetStateAction<boolean>>; setToggleSidebar: React.Dispatch<React.SetStateAction<boolean>>;
isAnalyticsModalOpen: boolean;
setAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
} }
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({ toggleSidebar, setToggleSidebar }) => {
toggleSidebar,
setToggleSidebar,
isAnalyticsModalOpen,
setAnalyticsModal,
}) => {
// theme // theme
const { collapsed: sidebarCollapse } = useTheme(); const { collapsed: sidebarCollapse } = useTheme();
@ -34,10 +27,7 @@ const Sidebar: React.FC<SidebarProps> = ({
> >
<div className="flex h-full flex-1 flex-col"> <div className="flex h-full flex-1 flex-col">
<WorkspaceSidebarDropdown /> <WorkspaceSidebarDropdown />
<WorkspaceSidebarMenu <WorkspaceSidebarMenu />
isAnalyticsModalOpen={isAnalyticsModalOpen}
setAnalyticsModal={setAnalyticsModal}
/>
<ProjectSidebarList /> <ProjectSidebarList />
<WorkspaceHelpSection setSidebarActive={setToggleSidebar} /> <WorkspaceHelpSection setSidebarActive={setToggleSidebar} />
</div> </div>

View File

@ -13,7 +13,6 @@ import AppHeader from "layouts/app-layout/app-header";
import AppSidebar from "layouts/app-layout/app-sidebar"; import AppSidebar from "layouts/app-layout/app-sidebar";
// components // components
import { NotAuthorizedView, JoinProject } from "components/auth-screens"; import { NotAuthorizedView, JoinProject } from "components/auth-screens";
import { AnalyticsWorkspaceModal } from "components/analytics";
import { CommandPalette } from "components/command-palette"; import { CommandPalette } from "components/command-palette";
// ui // ui
import { PrimaryButton, Spinner } from "components/ui"; import { PrimaryButton, Spinner } from "components/ui";
@ -53,13 +52,10 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
right, right,
}) => { }) => {
const [toggleSidebar, setToggleSidebar] = useState(false); const [toggleSidebar, setToggleSidebar] = useState(false);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { issueView } = useIssuesView();
const { loading, error, memberRole: memberType } = useProjectMyMembership(); const { loading, error, memberRole: memberType } = useProjectMyMembership();
const settingsLayout = router.pathname.includes("/settings"); const settingsLayout = router.pathname.includes("/settings");
@ -68,12 +64,7 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
<Container meta={meta}> <Container meta={meta}>
<CommandPalette /> <CommandPalette />
<div className="relative flex h-screen w-full overflow-hidden"> <div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar <AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
toggleSidebar={toggleSidebar}
setToggleSidebar={setToggleSidebar}
isAnalyticsModalOpen={analyticsModal}
setAnalyticsModal={setAnalyticsModal}
/>
{loading ? ( {loading ? (
<div className="grid h-full w-full place-items-center p-4"> <div className="grid h-full w-full place-items-center p-4">
@ -121,12 +112,6 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
: "bg-brand-base" : "bg-brand-base"
}`} }`}
> >
{analyticsModal && (
<AnalyticsWorkspaceModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
/>
)}
{!noHeader && ( {!noHeader && (
<AppHeader <AppHeader
breadcrumbs={breadcrumbs} breadcrumbs={breadcrumbs}

View File

@ -14,7 +14,6 @@ import AppHeader from "layouts/app-layout/app-header";
import { UserAuthorizationLayout } from "./user-authorization-wrapper"; import { UserAuthorizationLayout } from "./user-authorization-wrapper";
// components // components
import { NotAuthorizedView, NotAWorkspaceMember } from "components/auth-screens"; import { NotAuthorizedView, NotAWorkspaceMember } from "components/auth-screens";
import { AnalyticsWorkspaceModal } from "components/analytics";
import { CommandPalette } from "components/command-palette"; import { CommandPalette } from "components/command-palette";
// icons // icons
import { PrimaryButton, Spinner } from "components/ui"; import { PrimaryButton, Spinner } from "components/ui";
@ -49,7 +48,6 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
right, right,
}) => { }) => {
const [toggleSidebar, setToggleSidebar] = useState(false); const [toggleSidebar, setToggleSidebar] = useState(false);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -93,12 +91,7 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
<Container meta={meta}> <Container meta={meta}>
<CommandPalette /> <CommandPalette />
<div className="relative flex h-screen w-full overflow-hidden"> <div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar <AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
toggleSidebar={toggleSidebar}
setToggleSidebar={setToggleSidebar}
isAnalyticsModalOpen={analyticsModal}
setAnalyticsModal={setAnalyticsModal}
/>
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? ( {settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<NotAuthorizedView <NotAuthorizedView
actionButton={ actionButton={
@ -122,12 +115,6 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
: "bg-brand-base" : "bg-brand-base"
}`} }`}
> >
{analyticsModal && (
<AnalyticsWorkspaceModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
/>
)}
{!noHeader && ( {!noHeader && (
<AppHeader <AppHeader
breadcrumbs={breadcrumbs} breadcrumbs={breadcrumbs}

View File

@ -0,0 +1,108 @@
import React, { Fragment } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
const Analytics = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: watch("project"),
};
const { data: analytics, error: analyticsError } = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
return (
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Workspace Analytics" />
</Breadcrumbs>
}
// right={
// <PrimaryButton
// className="flex items-center gap-2"
// onClick={() => {
// const e = new KeyboardEvent("keydown", { key: "p" });
// document.dispatchEvent(e);
// }}
// >
// <PlusIcon className="h-4 w-4" />
// Save Analytics
// </PrimaryButton>
// }
>
<div className="h-full flex flex-col overflow-hidden bg-brand-base">
<Tab.Group as={Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-surface-2 ${
selected ? "bg-brand-surface-2" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics
analytics={analytics}
analyticsError={analyticsError}
params={params}
control={control}
setValue={setValue}
fullScreen
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</WorkspaceAuthorizationLayout>
);
};
export default Analytics;

View File

@ -18,7 +18,10 @@ class AnalyticsServices extends APIService {
async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> { async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/analytics/`, { return this.get(`/api/workspaces/${workspaceSlug}/analytics/`, {
params, params: {
...params,
project: params?.project ? params.project.toString() : null,
},
}) })
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -31,7 +34,10 @@ class AnalyticsServices extends APIService {
params?: Partial<IAnalyticsParams> params?: Partial<IAnalyticsParams>
): Promise<IDefaultAnalyticsResponse> { ): Promise<IDefaultAnalyticsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/default-analytics/`, { return this.get(`/api/workspaces/${workspaceSlug}/default-analytics/`, {
params, params: {
...params,
project: params?.project ? params.project.toString() : null,
},
}) })
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {

View File

@ -11,7 +11,7 @@ export interface IAnalyticsData {
dimension: string | null; dimension: string | null;
segment?: string; segment?: string;
count?: number; count?: number;
effort?: number | null; estimate?: number | null;
}[]; }[];
} }
@ -34,13 +34,13 @@ export type TXAxisValues =
| "created_at" | "created_at"
| "completed_at"; | "completed_at";
export type TYAxisValues = "issue_count" | "effort"; export type TYAxisValues = "issue_count" | "estimate";
export interface IAnalyticsParams { export interface IAnalyticsParams {
x_axis: TXAxisValues; x_axis: TXAxisValues;
y_axis: TYAxisValues; y_axis: TYAxisValues;
segment?: TXAxisValues | null; segment?: TXAxisValues | null;
project?: string | null; project?: string[] | null;
cycle?: string | null; cycle?: string | null;
module?: string | null; module?: string | null;
} }
@ -59,7 +59,8 @@ export interface IExportAnalyticsFormData {
export interface IDefaultAnalyticsUser { export interface IDefaultAnalyticsUser {
assignees__avatar: string | null; assignees__avatar: string | null;
assignees__email: string; assignees__first_name: string;
assignees__last_name: string;
count: number; count: number;
} }
@ -68,7 +69,8 @@ export interface IDefaultAnalyticsResponse {
most_issue_closed_user: IDefaultAnalyticsUser[]; most_issue_closed_user: IDefaultAnalyticsUser[];
most_issue_created_user: { most_issue_created_user: {
created_by__avatar: string | null; created_by__avatar: string | null;
created_by__email: string; created_by__first_name: string;
created_by__last_name: string;
count: number; count: number;
}[]; }[];
open_estimate_sum: number; open_estimate_sum: number;

View File

@ -27,6 +27,9 @@ export interface IProject {
network: number; network: number;
project_lead: IUser | string | null; project_lead: IUser | string | null;
slug: string; slug: string;
total_cycles: number;
total_members: number;
total_modules: number;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
workspace: IWorkspace | string; workspace: IWorkspace | string;