refactor: analytics (#2419)

* refactor: helper functions

* chore: updated all the page headers

* refactor: custom analytics

* refactor: project analytics modal
This commit is contained in:
Aaryan Khandelwal 2023-10-12 17:43:36 +05:30 committed by GitHub
parent f2c3ad442d
commit 404e6a0cfc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1420 additions and 1483 deletions

View File

@ -1,174 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Input, TextArea } from "@plane/ui";
// types
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
// types
type Props = {
isOpen: boolean;
handleClose: () => void;
params?: IAnalyticsParams;
};
type FormValues = {
name: string;
description: string;
};
const defaultValues: FormValues = {
name: "",
description: "",
};
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<FormValues>({
defaultValues,
});
const onClose = () => {
handleClose();
reset(defaultValues);
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug) return;
const payload: ISaveAnalyticsFormData = {
name: formData.name,
description: formData.description,
query_dict: {
x_axis: "priority",
y_axis: "issue_count",
...params,
project: params?.project ?? [],
},
};
await analyticsService
.saveAnalytics(workspaceSlug.toString(), payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Analytics saved successfully.",
});
onClose();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Analytics could not be saved. Please try again.",
})
);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Save Analytics
</Dialog.Title>
<div className="mt-5">
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full"
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
placeholder="Description"
className="mt-3 h-32 resize-none text-sm"
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}
/>
)}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Analytics"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,24 +1,20 @@
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { Control, UseFormSetValue, useForm } from "react-hook-form";
// hooks
import useProjects from "hooks/use-projects";
// components
import { AnalyticsGraph, AnalyticsSelectBar, AnalyticsSidebar, AnalyticsTable } from "components/analytics";
// ui
import { Button, Loader } from "@plane/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, IUser } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import useSWR from "swr";
import { useForm } from "react-hook-form";
import { observer } from "mobx-react-lite";
// services
import analyticsService from "services/analytics.service";
// components
import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSidebar } from "components/analytics";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
additionalParams?: Partial<IAnalyticsParams>;
fullScreen: boolean;
user?: IUser | undefined;
};
const defaultValues: IAnalyticsParams = {
@ -28,17 +24,20 @@ const defaultValues: IAnalyticsParams = {
project: null,
};
export const CustomAnalytics: React.FC<Props> = ({ fullScreen, user }) => {
export const CustomAnalytics: React.FC<Props> = observer((props) => {
const { additionalParams, fullScreen } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const { control, watch, setValue } = useForm({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: watch("project"),
project: projectId ? [projectId.toString()] : watch("project"),
...additionalParams,
};
const { data: analytics, error: analyticsError } = useSWR(
@ -48,80 +47,29 @@ export const CustomAnalytics: React.FC<Props> = ({ fullScreen, user }) => {
const isProjectLevel = projectId ? true : false;
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
const { projects } = useProjects();
return (
<div className={`overflow-hidden flex flex-col-reverse ${fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""}`}>
<div className="col-span-3 flex flex-col h-full overflow-hidden">
<AnalyticsSelectBar
<CustomAnalyticsSelectBar
control={control}
setValue={setValue}
projects={projects ?? []}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
{!analyticsError ? (
analytics ? (
analytics.total > 0 ? (
<div className="h-full overflow-y-auto">
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable analytics={analytics} barGraphData={barGraphData} params={params} yAxisKey={yAxisKey} />
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
</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-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<Button
variant="primary"
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
</div>
</div>
</div>
)}
<CustomAnalyticsMainContent
analytics={analytics}
error={analyticsError}
fullScreen={fullScreen}
params={params}
/>
</div>
<AnalyticsSidebar
<CustomAnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
user={user}
/>
</div>
);
};
});

View File

@ -6,7 +6,7 @@ import { CustomTooltip } from "./custom-tooltip";
import { BarGraph } from "components/ui";
// helpers
import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor } from "helpers/analytics.helper";
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
@ -21,21 +21,7 @@ type Props = {
fullScreen: boolean;
};
export const AnalyticsGraph: React.FC<Props> = ({
analytics,
barGraphData,
params,
yAxisKey,
fullScreen,
}) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "?";
return assignee.assignees__display_name || "?";
};
export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey, fullScreen }) => {
const generateYAxisTickValues = () => {
if (!analytics) return [];
@ -110,7 +96,7 @@ export const AnalyticsGraph: React.FC<Props> = ({
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? renderAssigneeName(datum.value)[0].toUpperCase()
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
@ -119,7 +105,13 @@ export const AnalyticsGraph: React.FC<Props> = ({
</g>
);
}
: undefined,
: (datum) => (
<g transform={`translate(${datum.x},${datum.y})`}>
<text x={0} y={21} textAnchor="middle" fontSize={10}>
{generateDisplayName(datum.value, analytics, params, "x_axis")}
</text>
</g>
),
}}
theme={{
axis: {},

View File

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

View File

@ -0,0 +1,85 @@
import { useRouter } from "next/router";
import { mutate } from "swr";
// components
import { AnalyticsGraph, AnalyticsTable } from "components/analytics";
// ui
import { Button, Loader } from "@plane/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
analytics: IAnalyticsResponse | undefined;
error: any;
fullScreen: boolean;
params: IAnalyticsParams;
};
export const CustomAnalyticsMainContent: React.FC<Props> = (props) => {
const { analytics, error, fullScreen, params } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
return (
<>
{!error ? (
analytics ? (
analytics.total > 0 ? (
<div className="h-full overflow-y-auto">
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable analytics={analytics} barGraphData={barGraphData} params={params} yAxisKey={yAxisKey} />
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
</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-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<Button
variant="primary"
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
</div>
</div>
</div>
)}
</>
);
};

View File

@ -1,80 +1,85 @@
// react-hook-form
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
// types
import { IAnalyticsParams, IProject } from "types";
import { IAnalyticsParams } 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 px-5 py-2.5 ${
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
} ${fullScreen ? "lg:grid-cols-4 md:py-5" : ""}`}
>
{!isProjectLevel && (
export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
const { control, setValue, params, fullScreen, isProjectLevel } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { project: projectStore } = useMobxStore();
const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : null;
return (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
fullScreen ? "lg:grid-cols-4 md:py-5" : ""
}`}
>
{!isProjectLevel && (
<div>
<h6 className="text-xs text-custom-text-200">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject value={value ?? undefined} onChange={onChange} projects={projectsList ?? undefined} />
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-custom-text-200">Project</h6>
<h6 className="text-xs text-custom-text-200">Measure (y-axis)</h6>
<Controller
name="project"
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => <SelectYAxis value={value} onChange={onChange} />}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject value={value} onChange={onChange} projects={projects} />
<SelectXAxis
value={value}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
/>
)}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} />
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-custom-text-200">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-custom-text-200">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-custom-text-200">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} />
)}
/>
</div>
</div>
);
);
});

View File

@ -4,9 +4,9 @@ import { CustomSearchSelect } from "components/ui";
import { IProject } from "types";
type Props = {
value: string[] | null | undefined;
value: string[] | undefined;
onChange: (val: string[] | null) => void;
projects: IProject[];
projects: IProject[] | undefined;
};
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
@ -29,7 +29,7 @@ export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) =>
label={
value && value.length > 0
? projects
.filter((p) => value.includes(p.id))
?.filter((p) => value.includes(p.id))
.map((p) => p.identifier)
.join(", ")
: "All projects"

View File

@ -34,8 +34,8 @@ export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => {
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
if (moduleId && item.value === "issue_module__module__name") return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>

View File

@ -25,8 +25,8 @@ export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
if (moduleId && item.value === "issue_module__module__name") return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>

View File

@ -1,347 +0,0 @@
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track_event.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { ArrowDownTrayIcon, ArrowPathIcon, CalendarDaysIcon, UserGroupIcon } from "@heroicons/react/24/outline";
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper";
// types
import {
IAnalyticsParams,
IAnalyticsResponse,
ICurrentUserResponse,
IExportAnalyticsFormData,
IWorkspace,
} from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants
import { NETWORK_CHOICES } from "constants/project";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
user: ICurrentUserResponse | undefined;
};
export const AnalyticsSidebar: React.FC<Props> = ({ analytics, params, fullScreen, isProjectLevel = false, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { projects } = useProjects();
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 trackExportAnalytics = () => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId
? "CYCLE_ANALYTICS_EXPORT"
: moduleId
? "MODULE_ANALYTICS_EXPORT"
: projectId
? "PROJECT_ANALYTICS_EXPORT"
: "WORKSPACE_ANALYTICS_EXPORT",
user
);
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = params.project;
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
});
trackExportAnalytics();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
const selectedProjects = params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
return (
<div
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
fullScreen
? "border-l border-custom-border-200 md:h-full md:border-l md:border-custom-border-200 md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
: ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<LayerDiagonalIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<CalendarDaysIcon className="h-3.5 w-3.5" />
{renderShortDate(
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<h4 className="font-medium">Selected Projects</h4>
<div className="space-y-6 mt-4 h-full overflow-y-auto">
{selectedProjects.map((projectId) => {
const project = projects?.find((p) => p.id === projectId);
if (project)
return (
<div key={project.id} className="w-full">
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>
<span className="text-custom-text-200 text-xs ml-1">({project.identifier})</span>
</h5>
</div>
<div className="mt-4 space-y-3 pl-2 w-full">
<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-custom-text-200" />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{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-custom-text-200">{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-custom-text-200" />
<h6>Total modules</h6>
</div>
<span className="text-custom-text-200">{project.total_modules}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{projectId ? (
cycleId && cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleDetails.owned_by?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">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-custom-text-200">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-words">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">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-custom-text-200">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?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
{renderEmoji(projectDetails.emoji)}
</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(projectDetails.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6>
<span>{NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ?? ""}</span>
</div>
</div>
</div>
)
) : null}
</>
) : null}
</div>
<div className="flex items-center gap-2 flex-wrap justify-self-end">
<Button
variant="neutral-primary"
prependIcon={<ArrowPathIcon className="h-3.5 w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
<Button variant="primary" prependIcon={<ArrowDownTrayIcon />} onClick={exportAnalytics}>
Export as CSV
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./projects-list";
export * from "./sidebar-header";
export * from "./sidebar";

View File

@ -0,0 +1,65 @@
// icons
import { Contrast, LayoutGrid, Users } from "lucide-react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper";
// types
import { IProject } from "types";
type Props = {
projects: IProject[];
};
export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = (props) => {
const { projects } = props;
return (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<h4 className="font-medium">Selected Projects</h4>
<div className="space-y-6 mt-4 h-full overflow-y-auto">
{projects.map((project) => (
<div key={project.id} className="w-full">
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(project.emoji)}</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">{renderEmoji(project.icon_prop)}</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>
<span className="text-custom-text-200 text-xs ml-1">({project.identifier})</span>
</h5>
</div>
<div className="mt-4 space-y-3 pl-2 w-full">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Users className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{project.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Contrast className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total cycles</h6>
</div>
<span className="text-custom-text-200">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<LayoutGrid className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total modules</h6>
</div>
<span className="text-custom-text-200">{project.total_modules}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,107 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { renderShortDate } from "helpers/date-time.helper";
// constants
import { NETWORK_CHOICES } from "constants/project";
export const CustomAnalyticsSidebarHeader = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { cycle: cycleStore, module: moduleStore, project: projectStore } = useMobxStore();
const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined;
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<>
{projectId ? (
cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleDetails.owned_by?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">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-custom-text-200">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderShortDate(cycleDetails.end_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">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-custom-text-200">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?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(projectDetails.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6>
<span>{NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ?? ""}</span>
</div>
</div>
</div>
)
) : null}
</>
);
});

View File

@ -0,0 +1,214 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import analyticsService from "services/analytics.service";
import trackEventServices from "services/track_event.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics";
// ui
import { Button } from "@plane/ui";
// icons
import { ArrowDownTrayIcon, ArrowPathIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
export const CustomAnalyticsSidebar: React.FC<Props> = observer(
({ analytics, params, fullScreen, isProjectLevel = false }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const { user: userStore, project: projectStore, cycle: cycleStore, module: moduleStore } = useMobxStore();
const user = userStore.currentUser;
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) ?? undefined
: undefined;
const trackExportAnalytics = () => {
if (!user) return;
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId
? "CYCLE_ANALYTICS_EXPORT"
: moduleId
? "MODULE_ANALYTICS_EXPORT"
: projectId
? "PROJECT_ANALYTICS_EXPORT"
: "WORKSPACE_ANALYTICS_EXPORT",
user
);
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = params.project;
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
});
trackExportAnalytics();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined;
// fetch cycle details
useEffect(() => {
if (!workspaceSlug || !projectId || !cycleId || cycleDetails) return;
cycleStore.fetchCycleWithId(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
}, [cycleId, cycleDetails, cycleStore, projectId, workspaceSlug]);
// fetch module details
useEffect(() => {
if (!workspaceSlug || !projectId || !moduleId || moduleDetails) return;
moduleStore.fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString());
}, [moduleId, moduleDetails, moduleStore, projectId, workspaceSlug]);
const selectedProjects = params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
return (
<div
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
fullScreen
? "border-l border-custom-border-200 md:h-full md:border-l md:border-custom-border-200 md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
: ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<LayerDiagonalIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<CalendarDaysIcon className="h-3.5 w-3.5" />
{renderShortDate(
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList
projects={projects?.filter((p) => selectedProjects.includes(p.id)) ?? []}
/>
)}
<CustomAnalyticsSidebarHeader />
</>
) : null}
</div>
<div className="flex items-center gap-2 flex-wrap justify-self-end">
<Button
variant="neutral-primary"
prependIcon={<ArrowPathIcon className="h-3.5 w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
<Button variant="primary" prependIcon={<ArrowDownTrayIcon />} onClick={exportAnalytics}>
Export as CSV
</Button>
</div>
</div>
);
}
);

View File

@ -1,15 +1,13 @@
// nivo
import { BarDatum } from "@nivo/bar";
// icons
import { PriorityIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// helpers
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
@ -21,112 +19,81 @@ type Props = {
yAxisKey: "count" | "estimate";
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
};
return (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-custom-border-200 whitespace-nowrap border-y border-custom-border-200">
<thead className="bg-custom-background-80">
<tr className="divide-x divide-custom-border-200 text-sm text-custom-text-100">
<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}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
}`}
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
<PriorityIcon priority={key as TIssuePriorities} />
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{params.segment === "assignees__id"
? renderAssigneeName(key)
: DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: key}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-custom-border-200">
{barGraphData.data.map((item, index) => (
<tr
key={`table-row-${index}`}
className="divide-x divide-custom-border-200 text-xs text-custom-text-200"
>
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-custom-border-200 whitespace-nowrap border-y border-custom-border-200">
<thead className="bg-custom-background-80">
<tr className="divide-x divide-custom-border-200 text-sm text-custom-text-100">
<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}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group" ? "capitalize" : ""
}`}
>
{params.x_axis === "priority" ? (
<PriorityIcon priority={item.name as TIssuePriorities} />
) : (
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(
`${item.name}`,
analytics,
params,
"x_axis"
),
}}
/>
)}
{params.x_axis === "assignees__id"
? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td
key={`segment-value-${index}`}
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
>
{item[key] ?? 0}
</td>
))
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
<PriorityIcon priority={key as TIssuePriorities} />
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{generateDisplayName(key, analytics, params, "segment")}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-custom-border-200">
{barGraphData.data.map((item, index) => (
<tr key={`table-row-${index}`} className="divide-x divide-custom-border-200 text-xs text-custom-text-200">
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" || params.x_axis === "state__group" ? "capitalize" : ""
}`}
>
{params.x_axis === "priority" ? (
<PriorityIcon priority={item.name as TIssuePriorities} />
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(`${item.name}`, analytics, params, "x_axis"),
}}
/>
)}
</tr>
))}
</tbody>
</table>
</div>
{generateDisplayName(`${item.name}`, analytics, params, "x_axis")}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td key={`segment-value-${index}`} className="whitespace-nowrap py-2 px-2.5 sm:pr-0">
{item[key] ?? 0}
</td>
))
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
</div>
);

View File

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

View File

@ -1,207 +0,0 @@
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
import { Tab } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track_event.service";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams, IWorkspace } from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
import useUserAuth from "hooks/use-user-auth";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const [fullScreen, setFullScreen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { user } = useUserAuth();
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 { 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 trackAnalyticsEvent = (tab: string) => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
const eventType = tab === "Scope and Demand" ? "SCOPE_AND_DEMAND_ANALYTICS" : "CUSTOM_ANALYTICS";
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId ? `CYCLE_${eventType}` : moduleId ? `MODULE_${eventType}` : `PROJECT_${eventType}`,
user
);
};
const handleClose = () => {
onClose();
};
return (
<div
className={`absolute top-0 z-30 h-full bg-custom-background-90 ${fullScreen ? "p-2 w-full" : "w-1/2"} ${
isOpen ? "right-0" : "-right-full"
} duration-300 transition-all`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">
Analytics for {cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-3 w-3" />
)}
</button>
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab)}
>
{tab}
</Tab>
))}
</Tab.List>
{/* <h4 className="p-5 pb-0">Analytics for</h4> */}
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics
analytics={analytics}
analyticsError={analyticsError}
params={params}
control={control}
setValue={setValue}
fullScreen={fullScreen}
user={user}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};

View File

@ -0,0 +1,37 @@
import { observer } from "mobx-react-lite";
// icons
import { Expand, Shrink, X } from "lucide-react";
type Props = {
fullScreen: boolean;
handleClose: () => void;
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
title: string;
};
export const ProjectAnalyticsModalHeader: React.FC<Props> = observer((props) => {
const { fullScreen, handleClose, setFullScreen, title } = props;
return (
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">Analytics for {title}</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
</button>
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={handleClose}
>
<X size={14} strokeWidth={2} />
</button>
</div>
</div>
);
});

View File

@ -0,0 +1,3 @@
export * from "./header";
export * from "./main-content";
export * from "./modal";

View File

@ -0,0 +1,113 @@
import React from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Tab } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import trackEventServices from "services/track_event.service";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// types
import { ICycle, IModule, IProject, IWorkspace } from "types";
// constants
import { ANALYTICS_TABS } from "constants/analytics";
type Props = {
fullScreen: boolean;
cycleDetails: ICycle | undefined;
moduleDetails: IModule | undefined;
projectDetails: IProject | undefined;
};
export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props) => {
const { fullScreen, cycleDetails, moduleDetails, projectDetails } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { user: userStore } = useMobxStore();
const user = userStore.currentUser;
const trackAnalyticsEvent = (tab: string) => {
if (!workspaceSlug || !user) return;
const eventPayload: any = {
workspaceSlug: workspaceSlug.toString(),
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
const eventType = tab === "scope_and_demand" ? "SCOPE_AND_DEMAND_ANALYTICS" : "CUSTOM_ANALYTICS";
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleDetails ? `CYCLE_${eventType}` : moduleDetails ? `MODULE_${eventType}` : `PROJECT_${eventType}`,
user
);
};
return (
<Tab.Group as={React.Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
{ANALYTICS_TABS.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab.key)}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels as={React.Fragment}>
<Tab.Panel as={React.Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={React.Fragment}>
<CustomAnalytics
additionalParams={{
cycle: cycleDetails?.id,
module: moduleDetails?.id,
}}
fullScreen={fullScreen}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
});

View File

@ -0,0 +1,70 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
// components
import { ProjectAnalyticsModalHeader, ProjectAnalyticsModalMainContent } from "components/analytics";
// types
import { ICycle, IModule, IProject } from "types";
type Props = {
isOpen: boolean;
onClose: () => void;
cycleDetails?: ICycle | undefined;
moduleDetails?: IModule | undefined;
projectDetails?: IProject | undefined;
};
export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, cycleDetails, moduleDetails, projectDetails } = props;
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
onClose();
};
return (
<Transition.Root appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<Transition.Child
as={React.Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
{/* TODO: fix full screen mode */}
<Dialog.Panel
className={`fixed z-20 bg-custom-background-100 top-0 right-0 h-full shadow-custom-shadow-md ${
fullScreen ? "w-full p-2" : "w-1/2"
}`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<ProjectAnalyticsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={cycleDetails?.name ?? moduleDetails?.name ?? projectDetails?.name ?? ""}
/>
<ProjectAnalyticsModalMainContent
fullScreen={fullScreen}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
projectDetails={projectDetails}
/>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -17,12 +17,7 @@ type Props = {
workspaceSlug: string;
};
export const AnalyticsLeaderboard: React.FC<Props> = ({
users,
title,
emptyStateMessage,
workspaceSlug,
}) => (
export const AnalyticsLeaderBoard: React.FC<Props> = ({ users, title, emptyStateMessage, workspaceSlug }) => (
<div className="p-3 border border-custom-border-200 rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (

View File

@ -5,7 +5,7 @@ import useSWR from "swr";
// services
import analyticsService from "services/analytics.service";
// components
import { AnalyticsDemand, AnalyticsLeaderboard, AnalyticsScope, AnalyticsYearWiseIssues } from "components/analytics";
import { AnalyticsDemand, AnalyticsLeaderBoard, AnalyticsScope, AnalyticsYearWiseIssues } from "components/analytics";
// ui
import { Button, Loader } from "@plane/ui";
// fetch-keys
@ -15,7 +15,9 @@ type Props = {
fullScreen?: boolean;
};
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
export const ScopeAndDemand: React.FC<Props> = (props) => {
const { fullScreen = true } = props;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -46,7 +48,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
<AnalyticsLeaderboard
<AnalyticsLeaderBoard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar,
firstName: user?.created_by__first_name,
@ -56,10 +58,10 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
id: user?.created_by__id,
}))}
title="Most issues created"
emptyStateMessage="Co-workers and the number issues created by them appears here."
emptyStateMessage="Co-workers and the number of issues created by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<AnalyticsLeaderboard
<AnalyticsLeaderBoard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar,
firstName: user?.assignees__first_name,
@ -69,7 +71,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
id: user?.assignees__id,
}))}
title="Most issues closed"
emptyStateMessage="Co-workers and the number issues closed by them appears here."
emptyStateMessage="Co-workers and the number of issues closed by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<div className={fullScreen ? "md:col-span-2" : ""}>

View File

@ -47,9 +47,7 @@ export const AllViews: React.FC = observer(() => {
return (
<div className="relative w-full h-full flex flex-col overflow-auto">
<div className="p-4">
<AppliedFiltersRoot />
</div>
<AppliedFiltersRoot />
<div className="w-full h-full">
{activeLayout === "list" ? (
<ListLayout />

View File

@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
@ -6,17 +6,25 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
import { ProjectAnalyticsModal } from "components/analytics";
// ui
import { Button } from "@plane/ui";
// icons
import { Plus } from "lucide-react";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
export const CycleIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const {
issueFilter: issueFilterStore,
cycle: cycleStore,
cycleIssueFilter: cycleIssueFilterStore,
project: projectStore,
} = useMobxStore();
@ -80,32 +88,60 @@ export const CycleIssuesHeader: React.FC = observer(() => {
[issueFilterStore, projectId, workspaceSlug]
);
const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined;
return (
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}
/>
<FiltersDropdown title="Filters">
<FilterSelection
filters={cycleIssueFilterStore.cycleFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</FiltersDropdown>
<FiltersDropdown title="View">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
/>
</FiltersDropdown>
</div>
<FiltersDropdown title="Filters">
<FilterSelection
filters={cycleIssueFilterStore.cycleFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="View">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
/>
</FiltersDropdown>
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
</div>
</>
);
});

View File

@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
@ -6,16 +6,28 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
import { ProjectAnalyticsModal } from "components/analytics";
// ui
import { Button } from "@plane/ui";
// icons
import { Plus } from "lucide-react";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
export const ModuleIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { issueFilter: issueFilterStore, moduleFilter: moduleFilterStore, project: projectStore } = useMobxStore();
const {
issueFilter: issueFilterStore,
module: moduleStore,
moduleFilter: moduleFilterStore,
project: projectStore,
} = useMobxStore();
const activeLayout = issueFilterStore.userDisplayFilters.layout;
@ -76,32 +88,60 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
[issueFilterStore, projectId, workspaceSlug]
);
const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined;
return (
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined}
/>
<FiltersDropdown title="Filters">
<FilterSelection
filters={moduleFilterStore.moduleFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</FiltersDropdown>
<FiltersDropdown title="View">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
/>
</FiltersDropdown>
</div>
<FiltersDropdown title="Filters">
<FilterSelection
filters={moduleFilterStore.moduleFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="View">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
/>
</FiltersDropdown>
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
</div>
</>
);
});

View File

@ -1,4 +1,5 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
@ -6,12 +7,19 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
import { ProjectAnalyticsModal } from "components/analytics";
// ui
import { Button } from "@plane/ui";
// icons
import { Plus } from "lucide-react";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
export const ProjectIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -78,32 +86,79 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
[issueFilterStore, projectId, workspaceSlug]
);
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={projectDetails ?? undefined}
/>
<FiltersDropdown title="Filters">
<FilterSelection
filters={issueFilterStore.userFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</FiltersDropdown>
<FiltersDropdown title="View">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
/>
</FiltersDropdown>
</div>
<FiltersDropdown title="Filters">
<FilterSelection
filters={issueFilterStore.userFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="View">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
/>
</FiltersDropdown>
{/* TODO: add inbox redirection here */}
{projectDetails?.inbox_view && (
// <Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`}>
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/inboxId`}>
<a>
<Button variant="neutral-primary" size="sm">
<span>Inbox</span>
{/* {inboxList && inboxList?.[0]?.pending_issue_count !== 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full text-custom-text-100 bg-custom-sidebar-background-80 border border-custom-sidebar-border-200">
{inboxList?.[0]?.pending_issue_count}
</span>
)} */}
</Button>
</a>
</Link>
)}
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
</div>
</>
);
});

View File

@ -7,6 +7,7 @@ import useSWR from "swr";
import { observer } from "mobx-react-lite";
// components
import {
CycleAppliedFiltersRoot,
CycleCalendarLayout,
CycleGanttLayout,
CycleKanBanLayout,
@ -51,18 +52,21 @@ export const CycleLayoutRoot: React.FC = observer(() => {
const activeLayout = issueFilterStore.userDisplayFilters.layout;
return (
<div className="w-full h-full">
{activeLayout === "list" ? (
<CycleListLayout />
) : activeLayout === "kanban" ? (
<CycleKanBanLayout />
) : activeLayout === "calendar" ? (
<CycleCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<CycleGanttLayout />
) : activeLayout === "spreadsheet" ? (
<CycleSpreadsheetLayout />
) : null}
<div className="relative w-full h-full flex flex-col overflow-auto">
<CycleAppliedFiltersRoot />
<div className="w-full h-full">
{activeLayout === "list" ? (
<CycleListLayout />
) : activeLayout === "kanban" ? (
<CycleKanBanLayout />
) : activeLayout === "calendar" ? (
<CycleCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<CycleGanttLayout />
) : activeLayout === "spreadsheet" ? (
<CycleSpreadsheetLayout />
) : null}
</div>
</div>
);
});

View File

@ -0,0 +1,77 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
export const CycleAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { project: projectStore, cycleIssueFilter: cycleIssueFilterStore } = useMobxStore();
const userFilters = cycleIssueFilterStore.cycleFilters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId || !cycleId) return;
// remove all values of the key if value is null
if (!value) {
cycleIssueFilterStore.updateCycleFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), {
[key]: null,
});
return;
}
// remove the passed value from the key
let newValues = cycleIssueFilterStore.cycleFilters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
cycleIssueFilterStore.updateCycleFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), {
[key]: newValues,
});
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
cycleIssueFilterStore.updateCycleFilters(workspaceSlug.toString(), projectId.toString(), cycleId?.toString(), {
...newFilters,
});
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""]}
/>
</div>
);
});

View File

@ -1,3 +1,4 @@
export * from "./cycle-root";
export * from "./date";
export * from "./filters-list";
export * from "./global-views-root";

View File

@ -63,13 +63,15 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""]}
/>
<div className="p-4">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""]}
/>
</div>
);
});

View File

@ -67,13 +67,15 @@ export const AppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""]}
/>
<div className="p-4">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""]}
/>
</div>
);
});

View File

@ -2,6 +2,8 @@ import React, { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/ui";
// icons
import { ChevronUp } from "lucide-react";
@ -28,20 +30,18 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
return (
<>
<Popover.Button as={React.Fragment}>
<button
type="button"
className={`outline-none border border-custom-border-200 text-xs rounded flex items-center gap-2 px-2 py-1.5 hover:bg-custom-background-80 ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
<Button
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
}
>
<div className="font-medium">{title}</div>
<div
className={`w-3.5 h-3.5 flex items-center justify-center transition-all ${open ? "" : "rotate-180"}`}
>
<ChevronUp width={14} strokeWidth={2} />
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span>
</div>
</button>
</Button>
</Popover.Button>
<Transition
as={Fragment}

View File

@ -51,9 +51,7 @@ export const ModuleAllLayouts: React.FC = observer(() => {
return (
<div className="relative w-full h-full flex flex-col overflow-auto">
<div className="p-4">
<ModuleAppliedFiltersRoot />
</div>
<ModuleAppliedFiltersRoot />
<div className="h-full w-full">
{activeLayout === "list" ? (
<ModuleListLayout />

View File

@ -1,9 +1,14 @@
// types
import { TXAxisValues, TYAxisValues } from "types";
export const ANALYTICS_TABS = [
{ key: "scope_and_demand", title: "Scope and Demand" },
{ key: "custom", title: "Custom Analytics" },
];
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] = [
{
value: "state__name",
value: "state_id",
label: "State name",
},
{
@ -15,7 +20,7 @@ export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
label: "Priority",
},
{
value: "labels__name",
value: "labels__id",
label: "Label",
},
{
@ -27,11 +32,11 @@ export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
label: "Estimate point",
},
{
value: "issue_cycle__cycle__name",
value: "issue_cycle__cycle_id",
label: "Cycle",
},
{
value: "issue_module__module__name",
value: "issue_module__module_id",
label: "Module",
},
{

View File

@ -1,7 +1,7 @@
// nivo
import { BarDatum } from "@nivo/bar";
// helpers
import { capitalizeFirstLetter, generateRandomColor } from "helpers/string.helper";
import { addSpaceIfCamelCase, capitalizeFirstLetter, generateRandomColor } from "helpers/string.helper";
// types
import { IAnalyticsData, IAnalyticsParams, IAnalyticsResponse, TStateGroups } from "types";
// constants
@ -69,8 +69,11 @@ export const generateBarColor = (
if (!analytics) return color;
if (params[type] === "state__name" || params[type] === "labels__name")
color = analytics?.extras?.colors.find((c) => c.name === value)?.color;
if (params[type] === "state_id")
color = analytics?.extras.state_details.find((s) => s.state_id === value)?.state__color;
if (params[type] === "labels__id")
color = analytics?.extras.label_details.find((l) => l.labels__id === value)?.labels__color ?? undefined;
if (params[type] === "state__group") color = STATE_GROUP_COLORS[value.toLowerCase() as TStateGroups];
@ -92,6 +95,42 @@ export const generateBarColor = (
return color ?? generateRandomColor(value);
};
export const generateDisplayName = (
value: string,
analytics: IAnalyticsResponse,
params: IAnalyticsParams,
type: "x_axis" | "segment"
): string => {
let displayName = addSpaceIfCamelCase(value);
if (!analytics) return displayName;
if (params[type] === "assignees__id")
displayName =
analytics?.extras.assignee_details.find((a) => a.assignees__id === value)?.assignees__display_name ??
"No assignee";
if (params[type] === "issue_cycle__cycle_id")
displayName =
analytics?.extras.cycle_details.find((c) => c.issue_cycle__cycle_id === value)?.issue_cycle__cycle__name ??
"None";
if (params[type] === "issue_module__module_id")
displayName =
analytics?.extras.module_details.find((m) => m.issue_module__module_id === value)?.issue_module__module__name ??
"None";
if (params[type] === "labels__id")
displayName = analytics?.extras.label_details.find((l) => l.labels__id === value)?.labels__name ?? "None";
if (params[type] === "state_id")
displayName = analytics?.extras.state_details.find((s) => s.state_id === value)?.state__name ?? "None";
if (DATE_KEYS.includes(params.segment ?? "")) displayName = renderMonthAndYear(value);
return displayName;
};
export const renderMonthAndYear = (date: string | number | null): string => {
if (!date || date === "") return "";

View File

@ -1,58 +1,45 @@
import React, { Fragment, useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
// headless ui
import { observer } from "mobx-react-lite";
import { Tab } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import analyticsService from "services/analytics.service";
import trackEventServices from "services/track_event.service";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout-legacy";
import { AppLayout } from "layouts/app-layout";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
import { WorkspaceAnalyticsHeader } from "components/headers";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { EmptyState } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// images
// assets
import emptyAnalytics from "public/empty-state/analytics.svg";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { AppLayout } from "layouts/app-layout";
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
import { WorkspaceAnalyticsHeader } from "components/headers/workspace-analytics";
const tabsList = ["Scope and Demand", "Custom Analytics"];
// constants
import { ANALYTICS_TABS } from "constants/analytics";
const AnalyticsPage = observer(() => {
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// store
const { workspace: workspaceStore, project: projectStore, user: userStore } = useMobxStore();
const { project: projectStore, user: userStore } = useMobxStore();
const user = userStore.currentUser;
const projects = workspaceSlug ? projectStore.projects[workspaceSlug?.toString()] : null;
// const { user } = useUserAuth();
// const { projects } = useProjects();
const trackAnalyticsEvent = (tab: string) => {
if (!user) return;
const eventPayload = {
workspaceSlug: workspaceSlug?.toString(),
};
const eventType =
tab === "Scope and Demand" ? "WORKSPACE_SCOPE_AND_DEMAND_ANALYTICS" : "WORKSPACE_CUSTOM_ANALYTICS";
tab === "scope_and_demand" ? "WORKSPACE_SCOPE_AND_DEMAND_ANALYTICS" : "WORKSPACE_CUSTOM_ANALYTICS";
trackEventServices.trackAnalyticsEvent(eventPayload, eventType, user);
};
@ -69,101 +56,36 @@ const AnalyticsPage = observer(() => {
}, [user, workspaceSlug]);
return (
// <WorkspaceAuthorizationLayout
// breadcrumbs={
// <Breadcrumbs>
// <BreadcrumbItem title="Workspace Analytics" />
// </Breadcrumbs>
// }
// >
// {projects ? (
// projects.length > 0 ? (
// <div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
// <Tab.Group as={Fragment}>
// <Tab.List as="div" className="space-x-2 border-b border-custom-border-200 px-5 py-3">
// {tabsList.map((tab) => (
// <Tab
// key={tab}
// className={({ selected }) =>
// `rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
// selected ? "bg-custom-background-80" : ""
// }`
// }
// onClick={() => trackAnalyticsEvent(tab)}
// >
// {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}
// user={user}
// fullScreen
// />
// </Tab.Panel>
// </Tab.Panels>
// </Tab.Group>
// </div>
// ) : (
// <EmptyState
// title="You can see your all projects' analytics here"
// description="Let's create your first project and analyze the stats with various graphs."
// image={emptyAnalytics}
// primaryButton={{
// icon: <PlusIcon className="h-4 w-4" />,
// text: "New Project",
// onClick: () => {
// const e = new KeyboardEvent("keydown", {
// key: "p",
// });
// document.dispatchEvent(e);
// },
// }}
// />
// )
// ) : null}
// </WorkspaceAuthorizationLayout>
<AppLayout header={<WorkspaceAnalyticsHeader />}>
<>
{projects && projects.length > 0 ? (
<>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<Tab.Group as={Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab)}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<Tab.Group as={Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 px-5 py-3">
{ANALYTICS_TABS.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab.key)}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<>
<EmptyState

View File

@ -130,9 +130,6 @@ const SingleCycle: React.FC = () => {
right={
<div className={`flex flex-shrink-0 items-center gap-2 duration-300`}>
<CycleIssuesHeader />
<Button variant="neutral-primary" onClick={() => setAnalyticsModal(true)}>
Analytics
</Button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
@ -159,8 +156,6 @@ const SingleCycle: React.FC = () => {
<>
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
<div
className={`relative w-full h-full flex flex-col overflow-auto ${cycleSidebar ? "mr-[24rem]" : ""} ${
analyticsModal ? "mr-[50%]" : ""

View File

@ -1,5 +1,3 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
@ -8,27 +6,21 @@ import useSWR from "swr";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import projectService from "services/project.service";
import inboxService from "services/inbox.service";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout-legacy";
// helper
import { truncateText } from "helpers/string.helper";
// components
import { AllViews } from "components/core";
import { AnalyticsProjectModal } from "components/analytics";
import { ProjectIssuesHeader } from "components/headers";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import type { NextPage } from "next";
// fetch-keys
import { PROJECT_DETAILS, INBOX_LIST } from "constants/fetch-keys";
import { PROJECT_DETAILS } from "constants/fetch-keys";
const ProjectIssues: NextPage = () => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -39,11 +31,6 @@ const ProjectIssues: NextPage = () => {
workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null
);
const { data: inboxList } = useSWR(
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
workspaceSlug && projectId ? () => inboxService.getInboxes(workspaceSlug as string, projectId as string) : null
);
// TODO: update the fetch keys
useSWR(
workspaceSlug && projectId ? "REVALIDATE_USER_PROJECT_FILTERS" : null,
@ -81,48 +68,9 @@ const ProjectIssues: NextPage = () => {
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Issues`} />
</Breadcrumbs>
}
right={
<ProjectIssuesHeader />
// <div className="flex items-center gap-2">
// <SecondaryButton
// onClick={() => setAnalyticsModal(true)}
// className="!py-1.5 rounded-md font-normal text-custom-sidebar-text-200 border-custom-border-200 hover:text-custom-text-100 hover:bg-custom-sidebar-background-90"
// outline
// >
// Analytics
// </SecondaryButton>
// {projectDetails && projectDetails.inbox_view && (
// <Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`}>
// <a>
// <SecondaryButton
// className="relative !py-1.5 rounded-md font-normal text-custom-sidebar-text-200 border-custom-border-200 hover:text-custom-text-100 hover:bg-custom-sidebar-background-90"
// outline
// >
// <span>Inbox</span>
// {inboxList && inboxList?.[0]?.pending_issue_count !== 0 && (
// <span className="absolute -top-1 -right-1 h-4 w-4 rounded-full text-custom-text-100 bg-custom-sidebar-background-80 border border-custom-sidebar-border-200">
// {inboxList?.[0]?.pending_issue_count}
// </span>
// )}
// </SecondaryButton>
// </a>
// </Link>
// )}
// <PrimaryButton
// className="flex items-center gap-2"
// onClick={() => {
// const e = new KeyboardEvent("keydown", { key: "c" });
// document.dispatchEvent(e);
// }}
// >
// <PlusIcon className="h-4 w-4" />
// Add Issue
// </PrimaryButton>
// </div>
}
right={<ProjectIssuesHeader />}
bg="secondary"
>
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
<div className="h-full w-full flex flex-col">
<AllViews />
</div>

View File

@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { ArrowLeftIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
// services
import modulesService from "services/modules.service";
// hooks
@ -14,9 +14,8 @@ import useUserAuth from "hooks/use-user-auth";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout-legacy";
// components
import { ExistingIssuesListModal, IssuesFilterView } from "components/core";
import { ExistingIssuesListModal } from "components/core";
import { ModuleDetailsSidebar } from "components/modules";
import { AnalyticsProjectModal } from "components/analytics";
// ui
import { CustomMenu, EmptyState } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -34,7 +33,6 @@ import { ModuleIssuesHeader } from "components/headers";
const SingleModule: React.FC = () => {
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
const [moduleSidebar, setModuleSidebar] = useState(false);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
@ -138,10 +136,9 @@ const SingleModule: React.FC = () => {
/>
) : (
<>
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
<div
className={`relative overflow-y-auto h-full flex flex-col ${moduleSidebar ? "mr-[24rem]" : ""} ${
analyticsModal ? "mr-[50%]" : ""
className={`relative overflow-y-auto h-full flex flex-col ${
moduleSidebar ? "mr-[24rem]" : ""
} duration-300`}
>
<ModuleAllLayouts />

View File

@ -1,12 +1,11 @@
// services
import APIService from "services/api.service";
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
const trackEvent = process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
// types
import type {
ICurrentUserResponse,
IUser,
ICycle,
IEstimate,
IGptResponse,
@ -17,7 +16,7 @@ import type {
IPageBlock,
IProject,
IState,
IView,
IProjectView,
IWorkspace,
IssueCommentReaction,
IssueReaction,
@ -50,18 +49,9 @@ type PagesEventType = "PAGE_CREATE" | "PAGE_UPDATE" | "PAGE_DELETE";
type ViewEventType = "VIEW_CREATE" | "VIEW_UPDATE" | "VIEW_DELETE";
type IssueCommentEventType =
| "ISSUE_COMMENT_CREATE"
| "ISSUE_COMMENT_UPDATE"
| "ISSUE_COMMENT_DELETE";
type IssueCommentEventType = "ISSUE_COMMENT_CREATE" | "ISSUE_COMMENT_UPDATE" | "ISSUE_COMMENT_DELETE";
type Toggle =
| "TOGGLE_CYCLE"
| "TOGGLE_MODULE"
| "TOGGLE_VIEW"
| "TOGGLE_PAGES"
| "TOGGLE_STATE"
| "TOGGLE_INBOX";
type Toggle = "TOGGLE_CYCLE" | "TOGGLE_MODULE" | "TOGGLE_VIEW" | "TOGGLE_PAGES" | "TOGGLE_STATE" | "TOGGLE_INBOX";
export type MiscellaneousEventType = `${Toggle}_ON` | `${Toggle}_OFF`;
@ -126,11 +116,7 @@ class TrackEventServices extends APIService {
super("/");
}
async trackWorkspaceEvent(
data: IWorkspace | any,
eventName: WorkspaceEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackWorkspaceEvent(data: IWorkspace | any, eventName: WorkspaceEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -160,19 +146,11 @@ class TrackEventServices extends APIService {
});
}
async trackProjectEvent(
data: Partial<IProject> | any,
eventName: ProjectEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackProjectEvent(data: Partial<IProject> | any, eventName: ProjectEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
if (
eventName !== "DELETE_PROJECT" &&
eventName !== "PROJECT_MEMBER_INVITE" &&
eventName !== "PROJECT_MEMBER_LEAVE"
)
if (eventName !== "DELETE_PROJECT" && eventName !== "PROJECT_MEMBER_INVITE" && eventName !== "PROJECT_MEMBER_LEAVE")
payload = {
workspaceId: data?.workspace_detail?.id,
workspaceName: data?.workspace_detail?.name,
@ -195,10 +173,7 @@ class TrackEventServices extends APIService {
});
}
async trackUserOnboardingCompleteEvent(
data: any,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackUserOnboardingCompleteEvent(data: any, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -214,10 +189,7 @@ class TrackEventServices extends APIService {
});
}
async trackUserTourCompleteEvent(
data: any,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackUserTourCompleteEvent(data: any, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -233,11 +205,7 @@ class TrackEventServices extends APIService {
});
}
async trackIssueEvent(
data: IIssue | any,
eventName: IssueEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackIssueEvent(data: IIssue | any, eventName: IssueEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -266,10 +234,7 @@ class TrackEventServices extends APIService {
});
}
async trackIssueMarkedAsDoneEvent(
data: any,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackIssueMarkedAsDoneEvent(data: any, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
url: "/api/track-event",
@ -292,7 +257,7 @@ class TrackEventServices extends APIService {
| "ISSUE_PROPERTY_UPDATE_ASSIGNEE"
| "ISSUE_PROPERTY_UPDATE_DUE_DATE"
| "ISSUE_PROPERTY_UPDATE_ESTIMATE",
user: ICurrentUserResponse | undefined
user: IUser
): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -311,7 +276,7 @@ class TrackEventServices extends APIService {
async trackIssueCommentEvent(
data: Partial<IIssueComment> | any,
eventName: IssueCommentEventType,
user: ICurrentUserResponse | undefined
user: IUser
): Promise<any> {
if (!trackEvent) return;
@ -343,7 +308,7 @@ class TrackEventServices extends APIService {
async trackIssueRelationEvent(
data: any,
eventName: "ISSUE_RELATION_CREATE" | "ISSUE_RELATION_DELETE",
user: ICurrentUserResponse
user: IUser
): Promise<any> {
if (!trackEvent) return;
@ -365,7 +330,7 @@ class TrackEventServices extends APIService {
| "ISSUE_MOVED_TO_MODULE"
| "ISSUE_MOVED_TO_CYCLE_IN_BULK"
| "ISSUE_MOVED_TO_MODULE_IN_BULK",
user: ICurrentUserResponse | undefined
user: IUser
): Promise<any> {
if (!trackEvent) return;
@ -382,7 +347,7 @@ class TrackEventServices extends APIService {
});
}
async trackIssueBulkDeleteEvent(data: any, user: ICurrentUserResponse | undefined): Promise<any> {
async trackIssueBulkDeleteEvent(data: any, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -398,11 +363,7 @@ class TrackEventServices extends APIService {
});
}
async trackIssueLabelEvent(
data: any,
eventName: IssueLabelEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackIssueLabelEvent(data: any, eventName: IssueLabelEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -418,11 +379,7 @@ class TrackEventServices extends APIService {
});
}
async trackStateEvent(
data: IState | any,
eventName: StateEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackStateEvent(data: IState | any, eventName: StateEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -451,11 +408,7 @@ class TrackEventServices extends APIService {
});
}
async trackCycleEvent(
data: ICycle | any,
eventName: CycleEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackCycleEvent(data: ICycle | any, eventName: CycleEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -484,11 +437,7 @@ class TrackEventServices extends APIService {
});
}
async trackModuleEvent(
data: IModule | any,
eventName: ModuleEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackModuleEvent(data: IModule | any, eventName: ModuleEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -517,11 +466,7 @@ class TrackEventServices extends APIService {
});
}
async trackPageEvent(
data: Partial<IPage> | any,
eventName: PagesEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackPageEvent(data: Partial<IPage> | any, eventName: PagesEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -553,7 +498,7 @@ class TrackEventServices extends APIService {
async trackPageBlockEvent(
data: Partial<IPageBlock> | IIssue,
eventName: PageBlocksEventType,
user: ICurrentUserResponse | undefined
user: IUser
): Promise<any> {
if (!trackEvent) return;
@ -594,11 +539,7 @@ class TrackEventServices extends APIService {
});
}
async trackAskGptEvent(
data: IGptResponse,
eventName: GptEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackAskGptEvent(data: IGptResponse, eventName: GptEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
const payload = {
@ -623,11 +564,7 @@ class TrackEventServices extends APIService {
});
}
async trackUseGPTResponseEvent(
data: IIssue | IPageBlock,
eventName: GptEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackUseGPTResponseEvent(data: IIssue | IPageBlock, eventName: GptEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -668,11 +605,7 @@ class TrackEventServices extends APIService {
});
}
async trackViewEvent(
data: IView,
eventName: ViewEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackViewEvent(data: IProjectView, eventName: ViewEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -699,11 +632,7 @@ class TrackEventServices extends APIService {
});
}
async trackMiscellaneousEvent(
data: any,
eventName: MiscellaneousEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackMiscellaneousEvent(data: any, eventName: MiscellaneousEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -719,11 +648,7 @@ class TrackEventServices extends APIService {
});
}
async trackAppIntegrationEvent(
data: any,
eventName: IntegrationEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackAppIntegrationEvent(data: any, eventName: IntegrationEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -739,11 +664,7 @@ class TrackEventServices extends APIService {
});
}
async trackGitHubSyncEvent(
data: any,
eventName: GitHubSyncEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackGitHubSyncEvent(data: any, eventName: GitHubSyncEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
return this.request({
@ -762,7 +683,7 @@ class TrackEventServices extends APIService {
async trackIssueEstimateEvent(
data: { estimate: IEstimate },
eventName: IssueEstimateEventType,
user: ICurrentUserResponse | undefined
user: IUser
): Promise<any> {
if (!trackEvent) return;
@ -792,16 +713,11 @@ class TrackEventServices extends APIService {
});
}
async trackImporterEvent(
data: any,
eventName: ImporterEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackImporterEvent(data: any, eventName: ImporterEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
if (eventName === "GITHUB_IMPORTER_DELETE" || eventName === "JIRA_IMPORTER_DELETE")
payload = data;
if (eventName === "GITHUB_IMPORTER_DELETE" || eventName === "JIRA_IMPORTER_DELETE") payload = data;
else
payload = {
workspaceId: data?.workspace_detail?.id,
@ -825,11 +741,7 @@ class TrackEventServices extends APIService {
});
}
async trackAnalyticsEvent(
data: any,
eventName: AnalyticsEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackAnalyticsEvent(data: any, eventName: AnalyticsEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
const payload = { ...data };
@ -845,11 +757,7 @@ class TrackEventServices extends APIService {
});
}
async trackExporterEvent(
data: any,
eventName: ExporterEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackExporterEvent(data: any, eventName: ExporterEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
const payload = { ...data };
@ -868,11 +776,7 @@ class TrackEventServices extends APIService {
}
// TODO: add types to the data
async trackInboxEvent(
data: any,
eventName: InboxEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackInboxEvent(data: any, eventName: InboxEventType, user: IUser): Promise<any> {
if (!trackEvent) return;
let payload: any;
@ -904,13 +808,12 @@ class TrackEventServices extends APIService {
async trackReactionEvent(
data: IssueReaction | IssueCommentReaction,
eventName: ReactionEventType,
user: ICurrentUserResponse | undefined
user: IUser
): Promise<any> {
if (!trackEvent) return;
let payload: any;
if (eventName === "ISSUE_REACTION_DELETE" || eventName === "ISSUE_COMMENT_REACTION_DELETE")
payload = data;
if (eventName === "ISSUE_REACTION_DELETE" || eventName === "ISSUE_COMMENT_REACTION_DELETE") payload = data;
else
payload = {
workspaceId: data?.workspace,
@ -929,11 +832,7 @@ class TrackEventServices extends APIService {
});
}
async trackProjectPublishSettingsEvent(
data: any,
eventName: string,
user: ICurrentUserResponse | undefined
): Promise<any> {
async trackProjectPublishSettingsEvent(data: any, eventName: string, user: IUser): Promise<any> {
if (!trackEvent) return;
const payload: any = data;

View File

@ -1,4 +1,4 @@
import { action, observable, makeObservable, runInAction } from "mobx";
import { action, computed, observable, makeObservable, runInAction } from "mobx";
// services
import { ProjectService } from "services/project.service";
import { ModuleService } from "services/modules.service";
@ -8,9 +8,11 @@ import { IIssue, IModule } from "types";
import { IIssueGroupWithSubGroupsStructure, IIssueGroupedStructure, IIssueUnGroupedStructure } from "./issue";
export interface IModuleStore {
// states
loader: boolean;
error: any | null;
// observables
moduleId: string | null;
modules: {
[project_id: string]: IModule[];
@ -26,33 +28,37 @@ export interface IModuleStore {
};
};
// actions
setModuleId: (moduleSlug: string) => void;
getModuleById: (moduleId: string) => IModule | null;
fetchModules: (workspaceSlug: string, projectId: string) => void;
fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => void;
// crud operations
createModule: (workspaceSlug: string, projectId: string, data: Partial<IModule>) => Promise<IModule>;
updateModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string, data: Partial<IModule>) => void;
deleteModule: (workspaceSlug: string, projectId: string, moduleId: string) => void;
addModuleToFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => void;
removeModuleFromFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => void;
// computed
projectModules: IModule[] | null;
}
class ModuleStore implements IModuleStore {
// states
loader: boolean = false;
error: any | null = null;
// observables
moduleId: string | null = null;
modules: {
[project_id: string]: IModule[];
} = {};
moduleDetails: {
[module_id: string]: IModule;
} = {};
issues: {
[module_id: string]: {
grouped: {
@ -69,15 +75,18 @@ class ModuleStore implements IModuleStore {
// root store
rootStore;
// services
projectService;
moduleService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// states
loader: observable,
error: observable.ref,
// observables
moduleId: observable.ref,
modules: observable.ref,
moduleDetails: observable.ref,
@ -86,6 +95,8 @@ class ModuleStore implements IModuleStore {
// actions
setModuleId: action,
getModuleById: action,
fetchModules: action,
fetchModuleDetails: action,
@ -94,9 +105,14 @@ class ModuleStore implements IModuleStore {
deleteModule: action,
addModuleToFavorites: action,
removeModuleFromFavorites: action,
// computed
projectModules: computed,
});
this.rootStore = _rootStore;
// services
this.projectService = new ProjectService();
this.moduleService = new ModuleService();
}
@ -104,9 +120,12 @@ class ModuleStore implements IModuleStore {
// computed
get projectModules() {
if (!this.rootStore.project.projectId) return null;
return this.modules[this.rootStore.project.projectId] || null;
}
getModuleById = (moduleId: string) => this.moduleDetails[moduleId] || null;
// actions
setModuleId = (moduleSlug: string) => {
this.moduleId = moduleSlug ?? null;

View File

@ -2,14 +2,11 @@ export interface IAnalyticsResponse {
total: number;
distribution: IAnalyticsData;
extras: {
colors: IAnalyticsExtra[];
assignee_details: {
assignees__id: string | null;
assignees__display_name: string | null;
assignees__avatar: string | null;
assignees__first_name: string;
assignees__last_name: string;
}[];
assignee_details: IAnalyticsAssigneeDetails[];
cycle_details: IAnalyticsCycleDetails[];
label_details: IAnalyticsLabelDetails[];
module_details: IAnalyticsModuleDetails[];
state_details: IAnalyticsStateDetails[];
};
}
@ -22,19 +19,44 @@ export interface IAnalyticsData {
}[];
}
export interface IAnalyticsExtra {
name: string;
color: string;
export interface IAnalyticsAssigneeDetails {
assignees__avatar: string | null;
assignees__display_name: string | null;
assignees__first_name: string;
assignees__id: string | null;
assignees__last_name: string;
}
export interface IAnalyticsCycleDetails {
issue_cycle__cycle__name: string | null;
issue_cycle__cycle_id: string | null;
}
export interface IAnalyticsLabelDetails {
labels__color: string | null;
labels__id: string | null;
labels__name: string | null;
}
export interface IAnalyticsModuleDetails {
issue_module__module__name: string | null;
issue_module__module_id: string | null;
}
export interface IAnalyticsStateDetails {
state__color: string;
state__name: string;
state_id: string;
}
export type TXAxisValues =
| "state__name"
| "state_id"
| "state__group"
| "labels__name"
| "labels__id"
| "assignees__id"
| "estimate_point"
| "issue_cycle__cycle__name"
| "issue_module__module__name"
| "issue_cycle__cycle_id"
| "issue_module__module_id"
| "priority"
| "start_date"
| "target_date"