feat: plane analytics (#1029)
* chore: global bar graph component * chore: global pie graph component * chore: global line graph component * chore: removed unnecessary file * chore: refactored global chart components to accept all props * chore: function to convert response to chart data * chore: global calendar graph component added * chore: global scatter plot graph component * feat: analytics boilerplate created * chore: null value for segment and project * chore: clean up file * chore: change project query param key * chore: export, refresh buttons, analytics table * fix: analytics fetch key error * chore: show only integer values in the y-axis * chore: custom x-axis tick values and bar colors * refactor: divide analytics page into granular components * chore: convert analytics page to modal, save analytics modal * fix: build error * fix: modal overflow issues, analytics loading screen * chore: custom tooltip, refactor: graphs folder structure * refactor: folder structure, chore: x-axis tick values for larger data * chore: code cleanup * chore: remove unnecessary files * fix: refresh analytics button on error * feat: scope and demand analytics * refactor: scope and demand and custom analytics folder structure * fix: dynamic import type * chore: minor updates * feat: project, cycle and module level analytics * style: project analytics modal * fix: merge conflicts
This commit is contained in:
parent
d7928f853d
commit
1a534a3c19
45 changed files with 1730 additions and 287 deletions
|
|
@ -0,0 +1,158 @@
|
|||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { 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 { Input, PrimaryButton, SecondaryButton, TextArea } from "components/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 {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
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 ? [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-brand-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-brand-base bg-brand-base 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-brand-base">
|
||||
Save Analytics
|
||||
</Dialog.Title>
|
||||
<div className="mt-5">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
width="full"
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
}}
|
||||
/>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Description"
|
||||
className="mt-3 h-32 resize-none text-sm"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Analytics"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
// components
|
||||
import {
|
||||
AnalyticsGraph,
|
||||
AnalyticsSidebar,
|
||||
AnalyticsTable,
|
||||
CreateUpdateAnalyticsModal,
|
||||
} from "components/analytics";
|
||||
// ui
|
||||
import { Loader, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
import { convertResponseToBarGraphData } from "constants/analytics";
|
||||
// types
|
||||
import { IAnalyticsParams } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
|
||||
const defaultValues: IAnalyticsParams = {
|
||||
x_axis: "priority",
|
||||
y_axis: "issue_count",
|
||||
segment: null,
|
||||
project: null,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isProjectLevel?: boolean;
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
export const CustomAnalytics: React.FC<Props> = ({ isProjectLevel = false, fullScreen = true }) => {
|
||||
const [saveAnalyticsModal, setSaveAnalyticsModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
|
||||
|
||||
const params: IAnalyticsParams = {
|
||||
x_axis: watch("x_axis"),
|
||||
y_axis: watch("y_axis"),
|
||||
segment: watch("segment"),
|
||||
project: isProjectLevel ? projectId?.toString() : watch("project"),
|
||||
cycle: isProjectLevel && cycleId ? cycleId.toString() : null,
|
||||
module: isProjectLevel && moduleId ? moduleId.toString() : null,
|
||||
};
|
||||
|
||||
const {
|
||||
data: analytics,
|
||||
error: analyticsError,
|
||||
mutate: mutateAnalytics,
|
||||
} = useSWR(
|
||||
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||
);
|
||||
|
||||
const yAxisKey = params.y_axis === "issue_count" ? "count" : "effort";
|
||||
const barGraphData = convertResponseToBarGraphData(
|
||||
analytics?.distribution,
|
||||
watch("segment") ? true : false,
|
||||
watch("y_axis")
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateAnalyticsModal
|
||||
isOpen={saveAnalyticsModal}
|
||||
handleClose={() => setSaveAnalyticsModal(false)}
|
||||
params={params}
|
||||
/>
|
||||
<div
|
||||
className={`overflow-y-auto ${
|
||||
fullScreen ? "grid grid-cols-4 h-full" : "flex flex-col-reverse"
|
||||
}`}
|
||||
>
|
||||
<div className="col-span-3">
|
||||
{!analyticsError ? (
|
||||
analytics ? (
|
||||
analytics.total > 0 ? (
|
||||
<>
|
||||
<AnalyticsGraph
|
||||
analytics={analytics}
|
||||
barGraphData={barGraphData}
|
||||
params={params}
|
||||
yAxisKey={yAxisKey}
|
||||
fullScreen={fullScreen}
|
||||
/>
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
barGraphData={barGraphData}
|
||||
params={params}
|
||||
yAxisKey={yAxisKey}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">
|
||||
No matching issues found. Try changing the parameters.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-6 p-5">
|
||||
<Loader.Item height="300px" />
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
</Loader>
|
||||
)
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">There was some error in fetching the data.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<PrimaryButton onClick={mutateAnalytics}>Refresh</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={fullScreen ? "h-full" : ""}>
|
||||
<AnalyticsSidebar
|
||||
analytics={analytics}
|
||||
params={params}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
setSaveAnalyticsModal={setSaveAnalyticsModal}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// nivo
|
||||
import { BarTooltipProps } from "@nivo/bar";
|
||||
// types
|
||||
import { IAnalyticsParams } from "types";
|
||||
|
||||
type Props = {
|
||||
datum: BarTooltipProps<any>;
|
||||
params: IAnalyticsParams;
|
||||
};
|
||||
|
||||
export const CustomTooltip: React.FC<Props> = ({ datum, params }) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-base p-2 text-xs">
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: datum.color,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`font-medium text-brand-secondary ${
|
||||
params.segment
|
||||
? params.segment === "priority" || params.segment === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
: params.x_axis === "priority" || params.x_axis === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{params.segment ? datum.id : datum.id === "count" ? "Issue count" : "Effort"}:
|
||||
</span>
|
||||
<span>{datum.value}</span>
|
||||
</div>
|
||||
);
|
||||
102
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
102
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// nivo
|
||||
import { BarDatum } from "@nivo/bar";
|
||||
// components
|
||||
import { CustomTooltip } from "./custom-tooltip";
|
||||
// ui
|
||||
import { BarGraph } from "components/ui";
|
||||
// helpers
|
||||
import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { generateBarColor } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
barGraphData: {
|
||||
data: BarDatum[];
|
||||
xAxisKeys: string[];
|
||||
};
|
||||
params: IAnalyticsParams;
|
||||
yAxisKey: "effort" | "count";
|
||||
fullScreen: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsGraph: React.FC<Props> = ({
|
||||
analytics,
|
||||
barGraphData,
|
||||
params,
|
||||
yAxisKey,
|
||||
fullScreen,
|
||||
}) => {
|
||||
const generateYAxisTickValues = () => {
|
||||
if (!analytics) return [];
|
||||
|
||||
let data: number[] = [];
|
||||
|
||||
if (params.segment)
|
||||
// find the total no of issues in each segment
|
||||
data = Object.keys(analytics.distribution).map((segment) => {
|
||||
let totalSegmentIssues = 0;
|
||||
|
||||
analytics.distribution[segment].map((s) => {
|
||||
totalSegmentIssues += s[yAxisKey] as number;
|
||||
});
|
||||
|
||||
return totalSegmentIssues;
|
||||
});
|
||||
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
|
||||
|
||||
const minValue = 0;
|
||||
const maxValue = Math.max(...data);
|
||||
|
||||
const valueRange = maxValue - minValue;
|
||||
|
||||
let tickInterval = 1;
|
||||
if (valueRange > 10) tickInterval = 2;
|
||||
if (valueRange > 50) tickInterval = 5;
|
||||
if (valueRange > 100) tickInterval = 10;
|
||||
if (valueRange > 200) tickInterval = 50;
|
||||
if (valueRange > 300) tickInterval = (Math.ceil(valueRange / 100) * 100) / 10;
|
||||
|
||||
const tickValues = [];
|
||||
let tickValue = minValue;
|
||||
while (tickValue <= maxValue) {
|
||||
tickValues.push(tickValue);
|
||||
tickValue += tickInterval;
|
||||
}
|
||||
|
||||
if (!tickValues.includes(maxValue)) tickValues.push(maxValue);
|
||||
|
||||
return tickValues;
|
||||
};
|
||||
|
||||
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
|
||||
|
||||
return (
|
||||
<BarGraph
|
||||
data={barGraphData.data}
|
||||
indexBy="name"
|
||||
keys={barGraphData.xAxisKeys}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 10,
|
||||
tickValues: generateYAxisTickValues(),
|
||||
}}
|
||||
colors={(datum) =>
|
||||
generateBarColor(
|
||||
params.segment ? `${datum.id}` : `${datum.indexValue}`,
|
||||
analytics,
|
||||
params,
|
||||
params.segment ? "segment" : "x_axis"
|
||||
)
|
||||
}
|
||||
tooltip={(datum) => <CustomTooltip datum={datum} params={params} />}
|
||||
height={fullScreen ? "400px" : "300px"}
|
||||
margin={{ right: 20, bottom: longestXAxisLabel.length * 5 + 20 }}
|
||||
theme={{
|
||||
axis: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
5
apps/app/components/analytics/custom-analytics/index.ts
Normal file
5
apps/app/components/analytics/custom-analytics/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./graph";
|
||||
export * from "./create-update-analytics-modal";
|
||||
export * from "./custom-analytics";
|
||||
export * from "./sidebar";
|
||||
export * from "./table";
|
||||
232
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
232
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, CustomSelect, PrimaryButton } from "components/ui";
|
||||
// icons
|
||||
import { ArrowPathIcon, ArrowUpTrayIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
params: IAnalyticsParams;
|
||||
control: Control<IAnalyticsParams, any>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
setSaveAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel?: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
analytics,
|
||||
params,
|
||||
control,
|
||||
setValue,
|
||||
setSaveAnalyticsModal,
|
||||
fullScreen,
|
||||
isProjectLevel = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { projects } = useProjects();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
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,
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "There was some error in exporting the analytics. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gap-4 p-5 ${
|
||||
fullScreen ? "border-l border-brand-base bg-brand-sidebar h-full" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`sticky top-5 ${fullScreen ? "space-y-4" : "space-y-2"}`}>
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
||||
<h5 className="text-lg font-medium">
|
||||
{analytics?.total ?? 0}{" "}
|
||||
<span className="text-xs font-normal text-brand-secondary">issues</span>
|
||||
</h5>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowPathIcon className="h-3 w-3" />
|
||||
Refresh
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={exportAnalytics}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpTrayIcon className="h-3 w-3" />
|
||||
Export analytics as CSV
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className={`${fullScreen ? "space-y-4" : "grid items-center gap-4 grid-cols-3"}`}>
|
||||
{isProjectLevel === false && (
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Project</h6>
|
||||
<Controller
|
||||
name="project"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={projects.find((p) => p.id === value)?.name ?? "All projects"}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
<CustomSelect.Option value={null}>All projects</CustomSelect.Option>
|
||||
{projects.map((project) => (
|
||||
<CustomSelect.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<span>
|
||||
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
>
|
||||
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
if (params.segment === val) setValue("segment", null);
|
||||
|
||||
onChange(val);
|
||||
}}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
{ANALYTICS_X_AXIS_VALUES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Segment</h6>
|
||||
<Controller
|
||||
name="segment"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<span>
|
||||
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
|
||||
<span className="text-brand-secondary">No value</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
|
||||
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||
if (params.x_axis === item.value) return null;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="flex items-center justify-end gap-2">
|
||||
<PrimaryButton className="py-1" onClick={() => setSaveAnalyticsModal(true)}>
|
||||
Save analytics
|
||||
</PrimaryButton>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
116
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
116
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// nivo
|
||||
import { BarDatum } from "@nivo/bar";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import {
|
||||
ANALYTICS_X_AXIS_VALUES,
|
||||
ANALYTICS_Y_AXIS_VALUES,
|
||||
generateBarColor,
|
||||
} from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
barGraphData: {
|
||||
data: BarDatum[];
|
||||
xAxisKeys: string[];
|
||||
};
|
||||
params: IAnalyticsParams;
|
||||
yAxisKey: "effort" | "count";
|
||||
};
|
||||
|
||||
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-brand-base whitespace-nowrap border-y border-brand-base">
|
||||
<thead className="bg-brand-base">
|
||||
<tr className="divide-x divide-brand-base text-sm text-brand-base">
|
||||
<th scope="col" className="py-3 px-2.5 text-left font-medium">
|
||||
{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" ? (
|
||||
getPriorityIcon(key)
|
||||
) : (
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded"
|
||||
style={{
|
||||
backgroundColor: generateBarColor(key, analytics, params, "segment"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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-brand-base">
|
||||
{barGraphData.data.map((item, index) => (
|
||||
<tr
|
||||
key={`table-row-${index}`}
|
||||
className="divide-x divide-brand-base text-xs text-brand-secondary"
|
||||
>
|
||||
<td
|
||||
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
|
||||
params.x_axis === "priority" ? "capitalize" : ""
|
||||
}`}
|
||||
>
|
||||
{params.x_axis === "priority" ? (
|
||||
getPriorityIcon(`${item.name}`)
|
||||
) : (
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: generateBarColor(
|
||||
`${item.name}`,
|
||||
analytics,
|
||||
params,
|
||||
"x_axis"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
))
|
||||
) : (
|
||||
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue