app/columns.tsx (644 lines of code) (raw):
"use client";
import { ColumnDef, Row } from "@tanstack/react-table";
import { NimbusRecipe } from "@/lib/nimbusRecipe";
import { PreviewLinkButton } from "@/components/ui/previewlinkbutton";
import {
ChevronsUpDown,
ChevronDown,
ChevronRight,
FileText,
SquareArrowOutUpRight,
} from "lucide-react";
import { PrettyDateRange } from "./dates";
import { InfoPopover } from "@/components/ui/infopopover";
import { getSurfaceData } from "@/lib/messageUtils";
import { HIDE_DASHBOARD_EXPERIMENTS } from "@/lib/experimentUtils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
function SurfaceTag(template: string, surface: string) {
const { tagColor, docs } = getSurfaceData(template);
const anchorTagClassName =
"text-primary visited:text-primary hover:text-secondary hover:bg-opacity-80 cursor-pointer hover:no-underline ";
const surfaceTagClassName =
"text-xs/[180%] text-nowrap px-2 py-1 inline rounded-md " + tagColor;
if (docs) {
return (
<a
className={anchorTagClassName + surfaceTagClassName}
href={docs}
target="_blank"
rel="noreferrer"
>
{surface}
</a>
);
} else {
return <div className={surfaceTagClassName}>{surface}</div>;
}
}
function OffsiteLink(href: string, linkText: any) {
return (
<a
href={href}
className="text-xs/[180%] whitespace-nowrap"
target="_blank"
rel="noreferrer"
>
{linkText}
<SquareArrowOutUpRight size={10} className="inline ml-1" />
</a>
);
}
// This type is used to define the shape of our data.
// NOTE: ctrPercent is undefined by default until set using getCTRPercent. It is
// made optional to help determine what's displayed in the Metrics column.
export type FxMSMessageInfo = {
product: "Desktop" | "Android";
id: string;
template: string;
surface: string;
segment: string;
ctrPercent?: number;
ctrPercentChange?: number;
ctrDashboardLink?: string;
previewLink?: string;
metrics: string;
impressions?: number;
hasMicrosurvey?: boolean;
hidePreview?: boolean;
};
const nimbusExperimentV7Schema = require("@mozilla/nimbus-schemas/schemas/NimbusExperimentV7.schema.json");
type NimbusExperiment = typeof nimbusExperimentV7Schema.properties;
export type RecipeInfo = {
product: "Desktop" | "Android";
id: string;
template?: string; // XXX template JSON name
surface?: string; // XXX template display name
segment?: string;
ctrPercent?: number;
ctrPercentChange?: number;
ctrDashboardLink?: string;
previewLink?: string;
metrics?: string;
experimenterLink?: string;
startDate: string | null;
endDate: string | null;
userFacingName?: string;
nimbusExperiment: NimbusExperiment;
isBranch?: boolean;
branches: BranchInfo[]; // XXX rename this to branchInfos to avoid confusion with the branches property inside NimbusExperiment
hasMicrosurvey?: boolean;
experimentBriefLink?: string;
};
export type BranchInfo = {
product: "Desktop" | "Android";
id: string;
slug: string;
surface?: string;
segment?: string;
ctrPercent?: number;
ctrPercentChange?: number;
ctrDashboardLink?: string;
previewLink?: string;
metrics?: string;
experimenterLink?: string;
startDate?: string;
endDate?: string;
userFacingName?: string;
nimbusExperiment: NimbusExperiment;
isBranch?: boolean;
template?: string;
screenshots?: string[];
description?: string;
impressions?: number;
hasMicrosurvey?: boolean;
};
export type RecipeOrBranchInfo = RecipeInfo | BranchInfo;
/**
* @returns an OffsiteLink linking to the Looker dashboard link if it exists,
* labelled with either the CTR percent or "Dashboard"
*/
function showCTRMetrics(
ctrDashboardLink?: string,
ctrPercent?: number,
impressions?: number,
) {
if (ctrDashboardLink && ctrPercent !== undefined && impressions) {
return (
<div>
{OffsiteLink(
ctrDashboardLink,
<>
{ctrPercent + "% CTR"} <br />
{impressions.toLocaleString() +
" impression" +
(impressions > 1 ? "s" : "")}
</>,
)}
</div>
);
} else if (ctrDashboardLink) {
return OffsiteLink(ctrDashboardLink, "Dashboard");
}
}
const previewURLInfoButton = (
<InfoPopover
content={
<p>
To make the Preview URLs work, load <code>about:config</code> in Firefox
and set{" "}
<code>browser.newtabpage.activity-stream.asrouter.devtoolsEnabled</code>{" "}
to true; Firefox 128 or newer is required.
</p>
}
iconStyle="ml-1 h-6 w-6 p-1 rounded-full cursor-pointer bg-gray-200/70 hover:text-slate-400/70 hover:bg-gray-300/70 border-0"
/>
);
const microsurveyBadge = (
<div className="inline px-2 py-1 bg-slate-300 text-foreground text-3xs rounded-md font-medium">
Microsurvey
</div>
);
function experimentBriefTooltip(link: string) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
className="flex items-center justify-center p-1 rounded-full text-primary bg-gray-200/70 hover:bg-gray-200/40 hover:text-primary/80 visited:text-inherit"
href={link}
target="_blank"
rel="noreferrer"
>
<FileText size={14} />
</a>
</TooltipTrigger>
<TooltipContent>
<p>See experiment brief</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function experimenterLinkTooltip(
link: string,
hasMicrosurvey: boolean,
id: string,
userFacingName?: string,
) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={link}
className="font-semibold text-sm flex flex-row items-center gap-x-1 text-primary visited:text-inherit hover:text-blue-800 no-underline whitespace-nowrap"
target="_blank"
rel="noreferrer"
>
{hasMicrosurvey && <> {microsurveyBadge} </>}
{userFacingName || id}
<SquareArrowOutUpRight size={13} className="overflow-visible" />
</a>
</TooltipTrigger>
<TooltipContent>
<p>Go to Experimenter page</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function filterBySurface(
row: Row<RecipeOrBranchInfo>,
filterValue: string,
): boolean {
if (row.original.surface) {
return row.original.surface
.toLowerCase()
.includes(filterValue.toLowerCase());
}
return false;
}
export const fxmsMessageColumns: ColumnDef<FxMSMessageInfo>[] = [
{
accessorKey: "id",
header: "Message ID",
cell: (props: any) => {
return (
<>
<div className="font-mono text-xs inline">
{/* Quick fix for long message IDs that can break the UI */}
{props.row.original.id.length <= 50
? props.row.original.id
: props.row.original.id.substring(0, 50) + "…"}
</div>
{props.row.original.hasMicrosurvey && <> {microsurveyBadge} </>}
</>
);
},
},
{
accessorKey: "surface",
header: "Surface",
cell: (props: any) => {
return SurfaceTag(
props.row.original.template,
props.row.original.surface,
);
},
meta: {
filterVariant: "text",
},
},
{
// accessorKey: "segment",
// header: "Segment",
// }, {
accessorKey: "metrics",
header: () => (
<div className="flex flex-row items-center">
Metrics
<InfoPopover
content={
<p>
The CTR and impressions metrics in this table are the primary
button clickthrough rates calculated over the <b>last 30 days</b>.
Clicking into the CTR value will direct you to the Looker
dashboard displaying the data.
</p>
}
iconStyle="ml-1 h-6 w-6 p-1 rounded-full cursor-pointer bg-gray-200/70 hover:text-slate-400/70 hover:bg-gray-300/70 border-0"
/>
</div>
),
cell: (props: any) => {
// XXX these dashboards are currently (incorrectly) empty.
// Until we debug and fix, we'll hide them.
//
// https://bugzilla.mozilla.org/show_bug.cgi?id=1889497
const hideDashboardMessages = [
"PDFJS_FEATURE_TOUR_A",
"PDFJS_FEATURE_TOUR_B",
"FIREFOX_VIEW_SPOTLIGHT",
"NEWTAB_POCKET_TOPICS_SURVEY",
"MULTISELECT_WITH_DESCRIPTIONS",
"DEVICE_MIGRATION_BACKUP_AND_SYNC_SPOTLIGHT_SYNC_CONTENT",
"DEVICE_MIGRATION_BACKUP_AND_SYNC_SPOTLIGHT_SYNC_AND_BACKUP_CONTENT",
];
if (hideDashboardMessages.includes(props.row.original.id)) {
return <></>;
}
const metrics = showCTRMetrics(
props.row.original.ctrDashboardLink,
props.row.original.ctrPercent,
props.row.original.impressions,
);
if (metrics) {
return metrics;
}
return <></>;
},
filterFn: (row, columnId, filterValue) => {
if (filterValue) {
// If the filterValue is set, then the hide message toggle must be checked.
// This means we only want to display messages with over `filterValue`
// impressions or messages that already don't display any impressions.
return (
(row.original.impressions &&
row.original.impressions >= filterValue) ||
!row.original.impressions
);
}
return true;
},
meta: {
filterVariant: "checkbox",
},
},
{
accessorKey: "previewLink",
header: () => (
<div className="flex flex-row items-center">
Visuals
{previewURLInfoButton}
</div>
),
cell: (props: any) => {
const supportedTypes = [
"infobar",
"spotlight",
"defaultaboutwelcome",
"feature_callout",
];
if (!supportedTypes.includes(props.row.original.template)) {
return <div />;
}
// Hide preview URL buttons for messages where we don't have the JSON
if (props.row.original.hidePreview) {
return <div />;
}
return <PreviewLinkButton previewLink={props.row.original.previewLink} />;
},
},
];
export const experimentColumns: ColumnDef<RecipeOrBranchInfo>[] = [
{
accessorKey: "dates",
header: ({ table }) => (
<div className="flex flex-row items-center gap-x-2">
<button
{...{
onClick: table.getToggleAllRowsExpandedHandler(),
}}
data-testid="toggleAllRowsButton"
aria-label="Toggle All Branches"
className="p-1 rounded-full bg-gray-200/70 hover:bg-gray-300/70"
>
{table.getIsAllRowsExpanded() ? (
<ChevronDown size={18} />
) : (
<ChevronsUpDown size={18} />
)}
</button>
Dates
</div>
),
cell: (props: any) => {
return (
<div className="flex flex-row items-center gap-x-2">
<div>
{props.row.getCanExpand() ? (
<button
{...{
onClick: props.row.getToggleExpandedHandler(),
style: { cursor: "pointer" },
}}
data-testid="toggleBranchRowsButton"
aria-label="Toggle Branches"
className="p-1 rounded-full bg-slate-100 hover:bg-slate-200"
>
{props.row.getIsExpanded() ? (
<ChevronDown size={18} />
) : (
<ChevronRight size={18} />
)}
</button>
) : null}
</div>
<PrettyDateRange
startDate={props.row.original.startDate}
endDate={props.row.original.endDate}
/>
</div>
);
},
},
{
accessorKey: "exp_or_branch",
header: "",
cell: (props: any) => {
if (props.row.original.userFacingName) {
return (
<div className="flex flex-row gap-x-2 items-start">
{props.row.original.experimentBriefLink &&
experimentBriefTooltip(props.row.original.experimentBriefLink)}
<div className="flex flex-col gap-y-1 w-[24rem] overflow-visible">
{experimenterLinkTooltip(
props.row.original.experimenterLink,
props.row.original.hasMicrosurvey,
props.row.original.id,
props.row.original.userFacingName,
)}
<div className="font-mono text-3xs">{props.row.original.id}</div>
</div>
</div>
);
}
const recipe = new NimbusRecipe(props.row.original.nimbusExperiment);
return (
<div className="ps-14">
<a
href={recipe.getBranchRecipeLink(props.row.original.slug)}
className="text-xs text-primary visited:text-inherit hover:text-blue-800 no-underline"
target="_blank"
rel="noreferrer"
>
{props.row.original.description || props.row.original.id}
<SquareArrowOutUpRight size={10} className="inline ml-1" />
</a>
<p className="font-mono text-3xs">{props.row.original.slug}</p>
</div>
);
},
},
{
accessorKey: "surface",
header: "Surface",
cell: (props: any) => {
return SurfaceTag(
props.row.original.template,
props.row.original.surface,
);
},
filterFn: (row, columnId, filterValue) => filterBySurface(row, filterValue),
meta: {
filterVariant: "text",
},
},
{
// accessorKey: "segment",
// header: "Segment",
// }, {
accessorKey: "metrics",
header: () => (
<div className="flex flex-row items-center">
Metrics
<InfoPopover
content={
<p>
The CTR and impressions metrics in this table are the primary
button clickthrough rates calculated over the{" "}
<b>time that the experiment is live</b>. Clicking into the CTR
value will direct you to the Looker dashboard displaying the data.
</p>
}
iconStyle="ml-1 h-6 w-6 p-1 rounded-full cursor-pointer bg-gray-200/70 hover:text-slate-400/70 hover:bg-gray-300/70 border-0"
/>
</div>
),
cell: (props: any) => {
if (
HIDE_DASHBOARD_EXPERIMENTS.includes(
props.row.original?.nimbusExperiment?.slug,
)
) {
return <></>;
}
const metrics = showCTRMetrics(
props.row.original.ctrDashboardLink,
props.row.original.ctrPercent,
props.row.original.impressions,
);
if (metrics) {
return metrics;
}
return <></>;
},
},
{
accessorKey: "other",
header: () => (
<div className="flex flex-row items-center">
Visuals
{previewURLInfoButton}
</div>
),
cell: (props: any) => {
if (props.row.original.previewLink == undefined) {
// XXX should figure out how to do this NimbusRecipe instantiation
// once per row (maybe useState?)
const recipe = new NimbusRecipe(props.row.original.nimbusExperiment);
if (
props.row.original.screenshots &&
props.row.original.screenshots.length > 0
) {
const branchLink = recipe.getBranchScreenshotsLink(
props.row.original.slug,
);
return OffsiteLink(branchLink, "Screenshots");
} else {
return null;
}
}
return <PreviewLinkButton previewLink={props.row.original.previewLink} />;
},
},
];
export const completedExperimentColumns: ColumnDef<RecipeOrBranchInfo>[] = [
{
accessorKey: "dates",
header: ({ table }) => (
<div className="flex flex-row items-center gap-x-2">
<button
{...{
onClick: table.getToggleAllRowsExpandedHandler(),
}}
data-testid="toggleAllRowsButton"
aria-label="Toggle All Branches"
className="p-1 rounded-full bg-gray-200/70 hover:bg-gray-300/70"
>
{table.getIsAllRowsExpanded() ? (
<ChevronDown size={18} />
) : (
<ChevronsUpDown size={18} />
)}
</button>
Dates
</div>
),
cell: (props: any) => {
return (
<div className="flex flex-row items-center gap-x-2">
<div>
{props.row.getCanExpand() ? (
<button
{...{
onClick: props.row.getToggleExpandedHandler(),
style: { cursor: "pointer" },
}}
data-testid="toggleBranchRowsButton"
aria-label="Toggle Branches"
className="p-1 rounded-full bg-slate-100 hover:bg-slate-200"
>
{props.row.getIsExpanded() ? (
<ChevronDown size={18} />
) : (
<ChevronRight size={18} />
)}
</button>
) : null}
</div>
<PrettyDateRange
startDate={props.row.original.startDate}
endDate={props.row.original.endDate}
/>
</div>
);
},
},
{
accessorKey: "exp_or_branch",
header: "",
cell: (props: any) => {
if (props.row.original.userFacingName) {
return (
<div className="flex flex-row gap-x-2 items-start">
{props.row.original.experimentBriefLink &&
experimentBriefTooltip(props.row.original.experimentBriefLink)}
<div className="flex flex-col gap-y-1 w-[24rem] overflow-visible">
{experimenterLinkTooltip(
props.row.original.experimenterLink,
props.row.original.hasMicrosurvey,
props.row.original.id,
props.row.original.userFacingName,
)}
<div className="font-mono text-3xs">{props.row.original.id}</div>
</div>
</div>
);
}
const recipe = new NimbusRecipe(props.row.original.nimbusExperiment);
return (
<div className="ps-6">
<a
href={recipe.getBranchRecipeLink(props.row.original.slug)}
className="text-xs text-primary visited:text-inherit hover:text-blue-800 no-underline"
target="_blank"
rel="noreferrer"
>
{props.row.original.description || props.row.original.id}
<SquareArrowOutUpRight size={10} className="inline ml-1" />
</a>
<p className="font-mono text-3xs">{props.row.original.slug}</p>
</div>
);
},
},
{
accessorKey: "surface",
header: "Surface",
cell: (props: any) => {
return SurfaceTag(
props.row.original.template,
props.row.original.surface,
);
},
filterFn: (row, columnId, filterValue) => filterBySurface(row, filterValue),
meta: {
filterVariant: "text",
},
},
{
accessorKey: "metrics",
header: () => "Metrics",
cell: (props: any) => {
if (
HIDE_DASHBOARD_EXPERIMENTS.includes(
props.row.original?.nimbusExperiment?.slug,
)
) {
return <></>;
}
if (props.row.original.ctrDashboardLink) {
return OffsiteLink(props.row.original.ctrDashboardLink, "Dashboard");
}
return <></>;
},
},
{
accessorKey: "other",
header: () => (
<div className="flex flex-row items-center">
Visuals
{previewURLInfoButton}
</div>
),
cell: (props: any) => {
if (props.row.original.previewLink == undefined) {
// XXX should figure out how to do this NimbusRecipe instantiation
// once per row (maybe useState?)
const recipe = new NimbusRecipe(props.row.original.nimbusExperiment);
if (
props.row.original.screenshots &&
props.row.original.screenshots.length > 0
) {
const branchLink = recipe.getBranchScreenshotsLink(
props.row.original.slug,
);
return OffsiteLink(branchLink, "Screenshots");
} else {
return null;
}
}
return <PreviewLinkButton previewLink={props.row.original.previewLink} />;
},
},
];