From da5391c895e7e4f4c6068c3cbe83e34038699836 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Sep 2024 00:56:15 -0500 Subject: [PATCH 1/4] add click custom tokens to report view --- cypress.config.ts | 4 ++ cypress/e2e/dashboard.cy.ts | 27 +++++++++ cypress/e2e/redirects.cy.ts | 4 +- src/app/login/LoginForm.tsx | 15 ++--- src/components/Button.tsx | 17 +++--- src/components/base.tsx | 6 +- src/hooks/useRows.ts | 48 ++++++++++++---- src/lib/types.ts | 2 + .../DataTable/HeadlessDataTable.tsx | 17 +++--- src/views/ReportView/DataTable/Row.tsx | 7 ++- src/views/ReportView/DataTable/RowWrapper.tsx | 5 +- .../ReportView/LowerControlPanel/index.tsx | 1 + src/views/ReportView/ReportChain/index.tsx | 55 ++++++++++++++++--- 13 files changed, 158 insertions(+), 50 deletions(-) create mode 100644 cypress/e2e/dashboard.cy.ts diff --git a/cypress.config.ts b/cypress.config.ts index 9a6ddf5..d13e07e 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ setupNodeEvents(on, config) { // implement node event listeners here }, + specPattern: [ + "cypress/e2e/redirects.cy.ts", + "cypress/e2e/dashboard.cy.ts", + ], }, env: { // Non-sensitive env vars hard-coded here. Example: diff --git a/cypress/e2e/dashboard.cy.ts b/cypress/e2e/dashboard.cy.ts new file mode 100644 index 0000000..edef026 --- /dev/null +++ b/cypress/e2e/dashboard.cy.ts @@ -0,0 +1,27 @@ +import { campaignSeedData, trafficSourceSeedData } from "../../prisma/seedData"; +import { Env } from "../../src/lib/types"; + +describe("Testing dashboard functionality", () => { + it("logs in successfully and traverses dashboard functionality", () => { + cy.visit("http://localhost:3000"); + cy.url().should("eq", "http://localhost:3000/login"); + + cy.get("[data-cy='username-input']").type(Cypress.env(Env.ROOT_USERNAME)); + cy.get("[data-cy='password-input']").type(Cypress.env(Env.ROOT_PASSWORD)); + cy.get("[data-cy='submit-button']").click(); + + cy.wait(1000 * 20); + + cy.url().should("eq", "http://localhost:3000/dashboard"); + + cy.get(`[data-cy='${campaignSeedData.name}']`).click(); + cy.get("[data-cy='report-button']").click(); + + cy.wait(1000 * 20); + + for (const token of trafficSourceSeedData.customTokens) { + cy.get("[data-cy='select-chain-link-index-0']").select(token.queryParam); + cy.wait(1000); + } + }); +}); diff --git a/cypress/e2e/redirects.cy.ts b/cypress/e2e/redirects.cy.ts index 7c2fa83..8720df4 100644 --- a/cypress/e2e/redirects.cy.ts +++ b/cypress/e2e/redirects.cy.ts @@ -1,11 +1,11 @@ -import { campaignSeedData, landingPageSeedData, offerSeedData } from "../../prisma/seedData"; +import { campaignSeedData, landingPageSeedData, offerSeedData, trafficSourceSeedData } from "../../prisma/seedData"; import { makeCampaignUrl, makeClickUrl, makePostbackUrl } from "../../src/lib/utils"; import { ECookieName, Env } from "../../src/lib/types"; describe("Testing campaign redirects", () => { it("redirects to the correct URLs", () => { // Campaign URL - cy.visit(makeCampaignUrl("http:", "localhost", "3001", campaignSeedData.publicId, [])); + cy.visit(makeCampaignUrl("http:", "localhost", "3001", campaignSeedData.publicId, trafficSourceSeedData.customTokens)); cy.url().should("eq", landingPageSeedData.url); cy.getCookie(ECookieName.CLICK_PUBLIC_ID, { domain: "localhost" }) diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index b6ff95d..447cd5d 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -27,11 +27,12 @@ export default function LoginForm() { action={handleLoginAction} className="flex flex-col gap-1" > - - + + @@ -39,15 +40,11 @@ export default function LoginForm() { ) } -function Input({ name, type }: { - name: string; - type: string; -}) { +function Input(props: React.ComponentPropsWithoutRef<"input">) { return ( ) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index ddc3ebe..f1e19ea 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,5 +1,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { Dataset } from "@/lib/types"; export const BUTTON_STYLE: React.CSSProperties = { border: "solid lightgrey 1px", @@ -7,13 +8,14 @@ export const BUTTON_STYLE: React.CSSProperties = { backgroundImage: "linear-gradient(0deg,var(--color-gray5),var(--color-white))", }; -export default function Button({ children, disabled, icon, onClick, text, className }: { - children?: React.ReactNode, - disabled?: boolean, - icon?: IconDefinition, - onClick: React.MouseEventHandler, - text?: string, - className?: string +export default function Button({ children, disabled, icon, onClick, text, className, dataset }: { + children?: React.ReactNode; + disabled?: boolean; + icon?: IconDefinition; + onClick: React.MouseEventHandler; + text?: string; + className?: string; + dataset?: Dataset; }) { function handleClick(e: React.MouseEvent) { if (disabled) return; @@ -26,6 +28,7 @@ export default function Button({ children, disabled, icon, onClick, text, classN className={(!disabled ? "cursor-pointer hover:opacity-70" : "opacity-40") + " flex justify-center items-center gap-2 px-2 py-2 border"} style={BUTTON_STYLE} + {...dataset} > {icon && } {text && {text}} diff --git a/src/components/base.tsx b/src/components/base.tsx index 6bc553f..ae29f6f 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -1,5 +1,7 @@ "use client"; +import { Dataset } from "@/lib/types"; + const BASE_COMPONENT_CLASSNAME = "w-full px-2 py-1"; const BASE_COMPONENT_STYLE = { border: "solid 1px grey", @@ -29,7 +31,7 @@ export function Input({ name = "", placeholder, value, onChange }: { ) } -export function Select({ name = "", value, onChange, children, disabled, className, style }: { +export function Select({ name = "", value, onChange, children, disabled, className, style, dataset }: { name?: string; value: string | number | readonly string[] | undefined; onChange: React.ChangeEventHandler; @@ -37,6 +39,7 @@ export function Select({ name = "", value, onChange, children, disabled, classNa disabled?: boolean; className?: string; style?: React.CSSProperties; + dataset?: Dataset; }) { return ( @@ -49,6 +52,7 @@ export function Select({ name = "", value, onChange, children, disabled, classNa style={{ ...BASE_COMPONENT_STYLE, ...style }} value={value} onChange={onChange} + {...dataset} > {children} diff --git a/src/hooks/useRows.ts b/src/hooks/useRows.ts index 6d9eb93..bbcca24 100644 --- a/src/hooks/useRows.ts +++ b/src/hooks/useRows.ts @@ -4,20 +4,24 @@ import { useState, useEffect } from "react"; import { itemNameToClickProp } from "@/lib/utils/maps"; import { useDataContext } from "@/contexts/DataContext"; import { TRow } from "@/views/ReportView/DataTable"; -import { EItemName, TClick, TPrimaryItemName, TPrimaryData } from "@/lib/types"; +import { EItemName, TClick, TPrimaryItemName, TPrimaryData, TToken } from "@/lib/types"; import { getPrimaryItemById, isPrimary } from "@/lib/utils"; +import { reportChainValueToItemName, TReportChainValue } from "@/views/ReportView/ReportChain"; const INCLUDE_UNKNOWN_ROWS = false; -export function useRows(clicks: TClick[], itemName: EItemName): [TRow[] | null, React.Dispatch>] { +export function useRows( + clicks: TClick[], + reportChainValue: TReportChainValue, +): [TRow[] | null, React.Dispatch>] { const { primaryData } = useDataContext(); const [rows, setRows] = useState(null); useEffect(() => { - const newRows = makeRows(primaryData, clicks, itemName, makeEnrichmentItems(itemName, primaryData)); + const newRows = makeRows(primaryData, clicks, reportChainValue, makeEnrichmentItems(reportChainValue, primaryData)); setRows(newRows); - }, [clicks.length, primaryData, itemName]); + }, [clicks.length, primaryData, reportChainValue]); return [rows, setRows]; } @@ -30,15 +34,27 @@ type TEnrichmentItem = { export function makeRows( primaryData: TPrimaryData, clicks: TClick[], - itemName: EItemName, - enrichmentItems?: TEnrichmentItem[] + reportChainValue: TReportChainValue, + enrichmentItems?: TEnrichmentItem[], ): TRow[] { const rows = new Map(); - const { primaryItemName } = isPrimary(itemName); + + const { itemName } = reportChainValueToItemName(reportChainValue); + + let primaryItemName: TPrimaryItemName | null = null; + if (itemName) { + primaryItemName = isPrimary(itemName).primaryItemName; + } for (const click of clicks) { - const clickProp = itemNameToClickProp(itemName); - const value = click[clickProp]; + let value: string | number | Date | TToken[] | null; + if (itemName) { + const clickProp = itemNameToClickProp(itemName); + value = click[clickProp]; + } else { + // Traverse click.tokens to find token that matches reportChainValue + value = click.tokens.find(token => token.queryParam === reportChainValue)?.value ?? null; + } if (typeof value === "number" || typeof value === "string") { if (!rows.has(value)) { @@ -72,7 +88,11 @@ export function makeRows( return Array.from(rows.values()); } -function newRowName(primaryData: TPrimaryData, primaryItemName: TPrimaryItemName | null, value: string | number): string { +function newRowName( + primaryData: TPrimaryData, + primaryItemName: TPrimaryItemName | null, + value: string | number, +): string { if (typeof value === "number" && primaryItemName !== null) { const primaryItem = getPrimaryItemById(primaryData, primaryItemName, value); if (primaryItem) return primaryItem.name; @@ -83,9 +103,15 @@ function newRowName(primaryData: TPrimaryData, primaryItemName: TPrimaryItemName return ""; } +function makeEnrichmentItems( + reportChainValue: TReportChainValue, + primaryData: TPrimaryData, +): TEnrichmentItem[] | undefined { + const { itemName, success } = reportChainValueToItemName(reportChainValue); + if (!success) return undefined; -function makeEnrichmentItems(itemName: EItemName, primaryData: TPrimaryData): TEnrichmentItem[] | undefined { const { primaryItemName } = isPrimary(itemName); if (!primaryItemName) return undefined; + return primaryData[primaryItemName]?.map(({ id, name }) => ({ id, name: name || "" })); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 27e7493..04cd07a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,6 +13,8 @@ export enum Env { CATCH_ALL_REDIRECT_URL = "CATCH_ALL_REDIRECT_URL", }; +export type Dataset = { [key: `data-${string}`]: string }; + type omissions = "id" | "createdAt" | "updatedAt"; type primaryItemName = "primaryItemName"; type publicId = "publicId"; diff --git a/src/views/ReportView/DataTable/HeadlessDataTable.tsx b/src/views/ReportView/DataTable/HeadlessDataTable.tsx index b199dcd..c30b37f 100644 --- a/src/views/ReportView/DataTable/HeadlessDataTable.tsx +++ b/src/views/ReportView/DataTable/HeadlessDataTable.tsx @@ -7,17 +7,18 @@ import { getReportChainColor } from "../ReportChain/colors"; import { TView } from "@/lib/store"; import { EItemName, TClick } from "@/lib/types"; import { BASE_Z_INDEX, DEPTH_MARGIN, ROW_HEIGHT, TColumn } from "."; +import { TReportChainValue } from "../ReportChain"; -export default function HeadlessDataTable({ clicks, itemName, columns, view, depth }: { +export default function HeadlessDataTable({ clicks, reportChainValue, columns, view, depth }: { clicks: TClick[]; - itemName?: EItemName; + reportChainValue?: TReportChainValue; columns: TColumn[]; view: TView; depth: number; }) { const newDepth = depth + 1; - return itemName + return reportChainValue ?
<_Rows clicks={clicks} - itemName={itemName} + reportChainValue={reportChainValue} columns={columns} view={view} depth={newDepth} @@ -36,14 +37,14 @@ export default function HeadlessDataTable({ clicks, itemName, columns, view, dep : ""; } -function _Rows({ clicks, itemName, columns, view, depth }: { +function _Rows({ clicks, reportChainValue, columns, view, depth }: { clicks: TClick[]; - itemName: EItemName; + reportChainValue: TReportChainValue; columns: TColumn[]; view: TView; depth: number; }) { - const [rows, setRows] = useRows(clicks, itemName); + const [rows, setRows] = useRows(clicks, reportChainValue); return ( <> @@ -58,7 +59,7 @@ function _Rows({ clicks, itemName, columns, view, depth }: { zIndex: BASE_Z_INDEX - depth, }} > - {itemName} + {reportChainValue}
{rows && <> diff --git a/src/views/ReportView/DataTable/Row.tsx b/src/views/ReportView/DataTable/Row.tsx index d6cd2cc..5cfcafb 100644 --- a/src/views/ReportView/DataTable/Row.tsx +++ b/src/views/ReportView/DataTable/Row.tsx @@ -43,7 +43,7 @@ export default function Row({ row, columns, onSelected, view, depth }: { function handleSelectionChange(selected: boolean) { if (depth > 0) return; - if (view.type === "report" && view.reportChain[0]?.itemName) return; + if (view.type === "report" && view.reportChain[0]?.value) return; onSelected(selected); } @@ -104,6 +104,7 @@ export default function Row({ row, columns, onSelected, view, depth }: { selected={row.selected} onClick={handleSelectionChange} dialogueMenuItems={dialogueMenuItems} + dataset={{ ["data-cy"]: row.name }} > @@ -113,7 +114,7 @@ export default function Row({ row, columns, onSelected, view, depth }: { checked={row.selected} onChange={() => handleSelectionChange(!row.selected)} /> - : (view?.type === "report" && view.reportChain[depth]?.itemName) && + : (view?.type === "report" && view.reportChain[depth]?.value) && < FontAwesomeIcon icon={open ? faChevronUp : faChevronDown} onClick={() => setOpen(prev => !prev)} @@ -134,7 +135,7 @@ export default function Row({ row, columns, onSelected, view, depth }: { {open && view?.type === "report" && { } }: { +export default function RowWrapper({ children, value = 0, dialogueMenuItems = [], style, dataset, selected, onClick = () => { } }: { children: React.ReactNode; value?: number; dialogueMenuItems?: TDialogueMenuItem[]; style?: React.CSSProperties; + dataset?: Dataset; // Having a selected value of undefined disables the hover, background color change, and onClick functionalities. // The title row should use undefined because we do not want the title row to have this funcionality, // and all other rows should use a boolean. @@ -43,6 +45,7 @@ export default function RowWrapper({ children, value = 0, dialogueMenuItems = [] style={{ ...style, height: `${ROW_HEIGHT}px` }} onClick={handleClick} onContextMenu={handleContextMenu} + {...dataset} > {children} diff --git a/src/views/ReportView/LowerControlPanel/index.tsx b/src/views/ReportView/LowerControlPanel/index.tsx index 77d498d..5b73bb7 100644 --- a/src/views/ReportView/LowerControlPanel/index.tsx +++ b/src/views/ReportView/LowerControlPanel/index.tsx @@ -97,6 +97,7 @@ export default function LowerControlPanel({ view, reportItemName, rows, setRows icon={faRandom} disabled={selectedRows.length < 1} onClick={handleNewReport} + dataset={{ ["data-cy"]: "report-button" }} /> {isPrimary(view.itemName).ok && <> diff --git a/src/views/ReportView/ReportChain/index.tsx b/src/views/ReportView/ReportChain/index.tsx index 896eca3..c98ad11 100644 --- a/src/views/ReportView/ReportChain/index.tsx +++ b/src/views/ReportView/ReportChain/index.tsx @@ -5,11 +5,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; import { Select, DummySelect } from "@/components/base"; import { getReportChainColor } from "./colors"; -import { EItemName } from "@/lib/types"; +import { EItemName, TClick } from "@/lib/types"; +import { useDataContext } from "@/contexts/DataContext"; export type TReportChain = [TReportChainLink, TReportChainLink]; +export type TReportChainValue = EItemName | string; export type TReportChainLink = null | { - itemName?: EItemName; + value?: TReportChainValue; }; export default function ReportChain({ reportChain, onChange, omissions = [], itemName }: { @@ -18,10 +20,12 @@ export default function ReportChain({ reportChain, onChange, omissions = [], ite omissions?: EItemName[]; itemName?: EItemName; }) { + const { clicks } = useDataContext(); + function handleChange(e: React.ChangeEvent, index: number) { let newReportChain = [...reportChain]; if (e.target.value) { - newReportChain[index] = { itemName: e.target.value as EItemName }; + newReportChain[index] = { value: e.target.value as TReportChainValue }; if (index <= newReportChain.length - 2) newReportChain[index + 1] = {}; } else { newReportChain = [{}, null]; @@ -48,12 +52,12 @@ export default function ReportChain({ reportChain, onChange, omissions = [], ite
onChange({ ...route, rules: [...route.rules, newRule(e.target.value as ERuleName)] })} + onChange={handleSelectChange} > - {Object.values(ERuleName) - .filter(ruleName => !route.rules.some(rule => rule.ruleName === ruleName)) + {[...Object.values(ERuleName), ...tokens.map(({ queryParam }) => queryParam)] + .filter(ruleName => !route.rules.some(rule => rule.ruleName === ruleName || rule.ruleName === toCustomRuleName(ruleName))) .map((ruleName, index) => (