feat: unify multi select behavior across the app

This commit is contained in:
Ben Senescu 2026-05-12 20:38:31 -04:00 committed by GitHub
parent 0bfdc56c45
commit 8773662b16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 530 additions and 211 deletions

View File

@ -0,0 +1,129 @@
import { ChevronDown, Download, Loader2, X } from "lucide-react";
import type { ReactNode } from "react";
export function TableBulkActionBar({
selectedCount,
selectedLabel = "selected",
actions,
onClear,
placement = "fixed",
}: {
selectedCount: number;
selectedLabel?: string;
actions: ReactNode;
onClear: () => void;
placement?: "fixed" | "inline";
}) {
if (selectedCount === 0) return null;
const wrapperClass =
placement === "fixed"
? "pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4"
: "flex justify-center";
const toolbarClass =
placement === "fixed"
? "pointer-events-auto flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-300/85 shadow-2xl backdrop-blur"
: "flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-200";
return (
<div className={wrapperClass}>
<div role="toolbar" aria-label="Bulk actions" className={toolbarClass}>
<div className="flex items-center gap-2 border-r border-base-content/10 px-3 py-2 text-sm">
<button
type="button"
aria-label="Clear selection"
className="-ml-1 rounded p-1 text-base-content/55 hover:bg-base-content/10 hover:text-base-content"
onClick={onClear}
>
<X className="size-3.5" />
</button>
<span className="font-medium tabular-nums">{selectedCount}</span>
<span className="text-base-content/60">{selectedLabel}</span>
</div>
{actions}
</div>
</div>
);
}
export function TableBulkActionButton({
icon,
children,
onClick,
disabled,
variant = "default",
}: {
icon?: ReactNode;
children: ReactNode;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "danger";
}) {
const color =
variant === "danger"
? "text-error hover:bg-error/10"
: "text-base-content/85 hover:bg-base-content/10";
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm disabled:opacity-50 ${color}`}
>
{icon}
{children}
</button>
);
}
export function TableBulkExportMenu({
actions,
busy,
}: {
actions: Array<{
label: ReactNode;
icon?: ReactNode;
onClick: () => void;
disabled?: boolean;
}>;
busy?: boolean;
}) {
return (
<div className="dropdown dropdown-top dropdown-end">
<button
type="button"
tabIndex={0}
disabled={busy}
aria-haspopup="menu"
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Download className="size-3.5" />
)}
Export
<ChevronDown className="size-3 opacity-60" />
</button>
<ul
tabIndex={0}
role="menu"
className="dropdown-content menu z-10 mb-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
{actions.map((action, index) => (
<li key={index}>
<button
type="button"
onClick={action.onClick}
disabled={busy || action.disabled}
>
{action.icon}
{action.label}
</button>
</li>
))}
</ul>
</div>
);
}

View File

@ -132,6 +132,7 @@ export function DomainOverviewPage({
currentSortOrder={state.currentSortOrder} currentSortOrder={state.currentSortOrder}
searchDraft={state.searchDraft} searchDraft={state.searchDraft}
selectedKeywords={state.selectedKeywords} selectedKeywords={state.selectedKeywords}
setSelectedKeywords={state.setSelectedKeywords}
visibleKeywords={state.visibleKeywords} visibleKeywords={state.visibleKeywords}
filteredKeywords={state.filteredKeywords} filteredKeywords={state.filteredKeywords}
pagedPages={state.pagedPages} pagedPages={state.pagedPages}

View File

@ -24,6 +24,11 @@ import {
import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
import type { import type {
DomainActiveTab, DomainActiveTab,
DomainOverviewData, DomainOverviewData,
@ -41,6 +46,7 @@ type Props = {
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
searchDraft: string; searchDraft: string;
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
visibleKeywords: string[]; visibleKeywords: string[];
filteredKeywords: KeywordRow[]; filteredKeywords: KeywordRow[];
pagedPages: PageRow[]; pagedPages: PageRow[];
@ -85,6 +91,7 @@ export function DomainResultsCard({
currentSortOrder, currentSortOrder,
searchDraft, searchDraft,
selectedKeywords, selectedKeywords,
setSelectedKeywords,
visibleKeywords, visibleKeywords,
filteredKeywords, filteredKeywords,
pagedPages, pagedPages,
@ -119,6 +126,10 @@ export function DomainResultsCard({
const exportTable = isKeywordsTab const exportTable = isKeywordsTab
? keywordsToTable(filteredKeywords) ? keywordsToTable(filteredKeywords)
: pagesToTable(pagedPages); : pagesToTable(pagedPages);
const selectedKeywordRows = filteredKeywords.filter((row) =>
selectedKeywords.has(row.keyword),
);
const selectedKeywordExportTable = keywordsToTable(selectedKeywordRows);
const handleCopy = async () => { const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2); const text = JSON.stringify(currentRows, null, 2);
@ -147,6 +158,28 @@ export function DomainResultsCard({
}); });
} }
}; };
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: selectedKeywordExportTable.headers,
rows: selectedKeywordExportTable.rows,
feature: "domain_overview",
});
};
const handleDownloadSelectionCsv = () => {
downloadCsv(
`${overview.domain}-selected-keywords.csv`,
buildCsv(
selectedKeywordExportTable.headers,
selectedKeywordExportTable.rows,
),
);
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: selectedKeywordRows.length,
scope: "selection",
});
};
return ( return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
@ -190,20 +223,6 @@ export function DomainResultsCard({
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{activeTab === "keywords" ? (
<button
className="btn btn-sm"
onClick={onSaveKeywords}
disabled={selectedKeywords.size === 0 || !canSaveKeywords}
title={
!canSaveKeywords && selectedKeywords.size > 0
? "Re-run search to save keywords for the selected location"
: undefined
}
>
<Save className="size-4" /> Save Keywords
</button>
) : null}
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm gap-1"> <div tabIndex={0} role="button" className="btn btn-sm gap-1">
<Download className="size-4" /> <Download className="size-4" />
@ -243,6 +262,38 @@ export function DomainResultsCard({
</div> </div>
</div> </div>
{activeTab === "keywords" ? (
<TableBulkActionBar
selectedCount={selectedKeywords.size}
onClear={() => setSelectedKeywords(new Set())}
actions={
<div className="flex items-center px-1.5">
<TableBulkActionButton
icon={<Save className="size-3.5" />}
onClick={onSaveKeywords}
disabled={!canSaveKeywords}
>
Save Keywords
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Download CSV",
icon: <Download className="size-4" />,
onClick: handleDownloadSelectionCsv,
},
]}
/>
</div>
}
/>
) : null}
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300"> <div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
{isKeywordsTab ? ( {isKeywordsTab ? (
<button <button

View File

@ -298,6 +298,7 @@ export function useDomainOverviewController({
searchDraft: domainFilters.searchDraft, searchDraft: domainFilters.searchDraft,
setSearchDraft: domainFilters.setSearchDraft, setSearchDraft: domainFilters.setSearchDraft,
selectedKeywords, selectedKeywords,
setSelectedKeywords,
currentSortOrder, currentSortOrder,
setSearchParams, setSearchParams,
showFilters, showFilters,

View File

@ -8,8 +8,13 @@ import {
Sheet, Sheet,
SlidersHorizontal, SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions"; import {
downloadKeywordResearchCsv,
KEYWORD_RESEARCH_HEADERS,
keywordResearchExportRow,
} from "@/client/features/keywords/state/keywordControllerActions";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import { import {
AreaTrendChart, AreaTrendChart,
OverviewStats, OverviewStats,
@ -26,6 +31,11 @@ import {
KeywordResearchPagination, KeywordResearchPagination,
useKeywordResearchPagination, useKeywordResearchPagination,
} from "./KeywordResearchPagination"; } from "./KeywordResearchPagination";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
const MONTH_SHORT_LABELS = [ const MONTH_SHORT_LABELS = [
"Jan", "Jan",
@ -129,6 +139,9 @@ function DesktopTableCard({ controller }: Props) {
: `Showing ${filteredRows.length} keywords`; : `Showing ${filteredRows.length} keywords`;
const canExport = filteredRows.length > 0; const canExport = filteredRows.length > 0;
const selectedExportRows = filteredRows
.filter((row) => selectedRows.has(row.keyword))
.map(keywordResearchExportRow);
const handleExportToSheets = () => { const handleExportToSheets = () => {
void exportTableToSheets({ void exportTableToSheets({
headers: KEYWORD_RESEARCH_HEADERS, headers: KEYWORD_RESEARCH_HEADERS,
@ -136,6 +149,21 @@ function DesktopTableCard({ controller }: Props) {
feature: "keyword_research", feature: "keyword_research",
}); });
}; };
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: KEYWORD_RESEARCH_HEADERS,
rows: selectedExportRows,
feature: "keyword_research",
});
};
const handleExportSelectionCsv = () => {
downloadKeywordResearchCsv(selectedExportRows);
captureClientEvent("data:export", {
source_feature: "keyword_research",
result_count: selectedExportRows.length,
scope: "selection",
});
};
return ( return (
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
@ -157,14 +185,6 @@ function DesktopTableCard({ controller }: Props) {
{keywordCountLabel} {keywordCountLabel}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<button
className="btn btn-ghost btn-sm gap-1"
onClick={controller.handleSaveKeywords}
disabled={selectedRows.size === 0}
>
<Save className="size-3.5" />
<span className="hidden lg:inline">Save Keywords</span>
</button>
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div <div
tabIndex={0} tabIndex={0}
@ -195,6 +215,35 @@ function DesktopTableCard({ controller }: Props) {
</div> </div>
</div> </div>
<TableBulkActionBar
selectedCount={selectedRows.size}
onClear={() => controller.setSelectedRows(new Set())}
actions={
<div className="flex items-center px-1.5">
<TableBulkActionButton
icon={<Save className="size-3.5" />}
onClick={controller.handleSaveKeywords}
>
Save Keywords
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Export CSV",
icon: <FileDown className="size-4" />,
onClick: handleExportSelectionCsv,
},
]}
/>
</div>
}
/>
{showFilters ? <DesktopFilters controller={controller} /> : null} {showFilters ? <DesktopFilters controller={controller} /> : null}
<KeywordResearchDesktopTable <KeywordResearchDesktopTable
activeFilterCount={controller.activeFilterCount} activeFilterCount={controller.activeFilterCount}

View File

@ -7,8 +7,13 @@ import {
Sheet, Sheet,
SlidersHorizontal, SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions"; import {
downloadKeywordResearchCsv,
KEYWORD_RESEARCH_HEADERS,
keywordResearchExportRow,
} from "@/client/features/keywords/state/keywordControllerActions";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import { SerpAnalysisCard } from "@/client/features/keywords/components"; import { SerpAnalysisCard } from "@/client/features/keywords/components";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
import { import {
@ -16,6 +21,11 @@ import {
useKeywordResearchPagination, useKeywordResearchPagination,
} from "./KeywordResearchPagination"; } from "./KeywordResearchPagination";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
type Props = { type Props = {
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
@ -89,6 +99,9 @@ function MobileKeywordResults({ controller }: Props) {
: `Showing ${filteredRows.length} keywords`; : `Showing ${filteredRows.length} keywords`;
const canExport = filteredRows.length > 0; const canExport = filteredRows.length > 0;
const selectedExportRows = filteredRows
.filter((row) => selectedRows.has(row.keyword))
.map(keywordResearchExportRow);
const handleExportToSheets = () => { const handleExportToSheets = () => {
void exportTableToSheets({ void exportTableToSheets({
headers: KEYWORD_RESEARCH_HEADERS, headers: KEYWORD_RESEARCH_HEADERS,
@ -96,6 +109,21 @@ function MobileKeywordResults({ controller }: Props) {
feature: "keyword_research", feature: "keyword_research",
}); });
}; };
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: KEYWORD_RESEARCH_HEADERS,
rows: selectedExportRows,
feature: "keyword_research",
});
};
const handleExportSelectionCsv = () => {
downloadKeywordResearchCsv(selectedExportRows);
captureClientEvent("data:export", {
source_feature: "keyword_research",
result_count: selectedExportRows.length,
scope: "selection",
});
};
return ( return (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
@ -127,13 +155,6 @@ function MobileKeywordResults({ controller }: Props) {
{keywordCountLabel} {keywordCountLabel}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<button
className="btn btn-ghost btn-xs"
onClick={controller.handleSaveKeywords}
disabled={selectedRows.size === 0}
>
<Save className="size-3.5" />
</button>
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div <div
tabIndex={0} tabIndex={0}
@ -164,6 +185,35 @@ function MobileKeywordResults({ controller }: Props) {
</div> </div>
</div> </div>
<TableBulkActionBar
selectedCount={selectedRows.size}
onClear={() => controller.setSelectedRows(new Set())}
actions={
<div className="flex items-center px-1.5">
<TableBulkActionButton
icon={<Save className="size-3.5" />}
onClick={controller.handleSaveKeywords}
>
Save
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Export CSV",
icon: <FileDown className="size-4" />,
onClick: handleExportSelectionCsv,
},
]}
/>
</div>
}
/>
{showFilters ? <MobileFilters controller={controller} /> : null} {showFilters ? <MobileFilters controller={controller} /> : null}
<KeywordResearchDesktopTable <KeywordResearchDesktopTable

View File

@ -22,7 +22,7 @@ export const KEYWORD_RESEARCH_HEADERS = [
"Intent", "Intent",
]; ];
function keywordResearchRow(row: KeywordResearchRow): CsvValue[] { export function keywordResearchExportRow(row: KeywordResearchRow): CsvValue[] {
return [ return [
row.keyword, row.keyword,
row.searchVolume ?? "", row.searchVolume ?? "",
@ -145,12 +145,8 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
}; };
const sheetsExportRows: CsvValue[][] = useMemo( const sheetsExportRows: CsvValue[][] = useMemo(
() => () => filteredRows.map(keywordResearchExportRow),
(selectedRows.size > 0 [filteredRows],
? filteredRows.filter((row) => selectedRows.has(row.keyword))
: filteredRows
).map(keywordResearchRow),
[filteredRows, selectedRows],
); );
const exportCsv = () => { const exportCsv = () => {
@ -158,18 +154,7 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
toast.error("No data to export"); toast.error("No data to export");
return; return;
} }
// CSV file keeps cents-formatted CPC/competition for human readability. downloadKeywordResearchCsv(sheetsExportRows);
const csvRows = sheetsExportRows.map((row) =>
row.map((cell, idx) =>
(idx === 2 || idx === 3) && typeof cell === "number"
? cell.toFixed(2)
: cell,
),
);
downloadCsv(
"keyword-research.csv",
buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
);
captureClientEvent("data:export", { captureClientEvent("data:export", {
source_feature: "keyword_research", source_feature: "keyword_research",
result_count: sheetsExportRows.length, result_count: sheetsExportRows.length,
@ -178,3 +163,18 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows }; return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows };
} }
export function downloadKeywordResearchCsv(rows: CsvValue[][]) {
// CSV file keeps cents-formatted CPC/competition for human readability.
const csvRows = rows.map((row) =>
row.map((cell, idx) =>
(idx === 2 || idx === 3) && typeof cell === "number"
? cell.toFixed(2)
: cell,
),
);
downloadCsv(
"keyword-research.csv",
buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
);
}

View File

@ -5,8 +5,11 @@ import {
type RowSelectionState, type RowSelectionState,
type SortingState, type SortingState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { Loader2, AlertCircle, X } from "lucide-react"; import { Loader2, AlertCircle, FileDown, Sheet, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain"; import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -15,6 +18,11 @@ import {
makeSelectionColumn, makeSelectionColumn,
useAppTable, useAppTable,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
import { SortableHeader } from "./RankTrackingColumns"; import { SortableHeader } from "./RankTrackingColumns";
import { import {
applyShiftRangeSelection, applyShiftRangeSelection,
@ -29,6 +37,12 @@ type SuggestedKeyword = {
}; };
const PRE_SELECT_COUNT = 20; const PRE_SELECT_COUNT = 20;
const SUGGESTED_KEYWORD_EXPORT_HEADERS = [
"Keyword",
"Position",
"Volume",
"Traffic",
];
const baseColumns: ColumnDef<SuggestedKeyword>[] = [ const baseColumns: ColumnDef<SuggestedKeyword>[] = [
{ {
@ -228,6 +242,32 @@ export function KeywordSuggestionStep({
addMutation.mutate(selectedKeywords); addMutation.mutate(selectedKeywords);
} }
}; };
const selectedSuggestionRows = table
.getSelectedRowModel()
.rows.map((row) => row.original);
const selectedSuggestionExportRows = selectedSuggestionRows.map((row) => [
row.keyword,
row.position ?? "",
row.searchVolume ?? "",
row.traffic ?? "",
]);
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: SUGGESTED_KEYWORD_EXPORT_HEADERS,
rows: selectedSuggestionExportRows,
feature: "rank_tracking",
});
};
const handleExportSelectionCsv = () => {
downloadCsv(
`rank-tracking-suggestions-${domain}.csv`,
buildCsv(SUGGESTED_KEYWORD_EXPORT_HEADERS, selectedSuggestionExportRows),
);
captureClientEvent("rank_tracking:suggestions_export_csv", {
result_count: selectedSuggestionRows.length,
scope: "selection",
});
};
const sectionHeader = (title: string) => ( const sectionHeader = (title: string) => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -307,9 +347,6 @@ export function KeywordSuggestionStep({
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/60">
We found {data.length} keywords {domain} ranks for. We found {data.length} keywords {domain} ranks for.
</p> </p>
<span className="text-xs text-base-content/50">
{selectedCount} of {data.length} selected
</span>
</div> </div>
<AppDataTable <AppDataTable
@ -329,20 +366,45 @@ export function KeywordSuggestionStep({
})} })}
/> />
<div className="flex items-center justify-end gap-3 pt-1"> <div className="flex items-center justify-between gap-3 pt-1">
<button className="btn btn-ghost btn-sm" onClick={onClose}> <button className="btn btn-ghost btn-sm" onClick={onClose}>
Skip Skip
</button> </button>
<button <TableBulkActionBar
className="btn btn-primary btn-sm" selectedCount={selectedCount}
disabled={selectedCount === 0 || addMutation.isPending} selectedLabel={`of ${data.length} selected`}
onClick={handleAdd} onClear={() => table.resetRowSelection()}
> placement="inline"
{addMutation.isPending && ( actions={
<Loader2 className="size-3.5 animate-spin" /> <div className="flex items-center px-1.5">
)} <TableBulkActionButton
Add {selectedCount} Keyword{selectedCount !== 1 ? "s" : ""} icon={
</button> addMutation.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : undefined
}
onClick={handleAdd}
disabled={addMutation.isPending}
>
Add Keyword{selectedCount !== 1 ? "s" : ""}
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Export CSV",
icon: <FileDown className="size-4" />,
onClick: handleExportSelectionCsv,
},
]}
/>
</div>
}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,16 +1,26 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Trash2 } from "lucide-react"; import { FileDown, Loader2, Sheet, Trash2 } from "lucide-react";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
import { import {
AppDataTable, AppDataTable,
useAppTable, useAppTable,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
import { buildCsv } from "@/client/lib/csv";
import { downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { useRankTrackingColumns } from "./RankTrackingColumns"; import { useRankTrackingColumns } from "./RankTrackingColumns";
import { buildRankTrackingExport } from "./RankTrackingTableParts";
import type { SelectionAnchor } from "@/client/components/table/tableSelection"; import type { SelectionAnchor } from "@/client/components/table/tableSelection";
export function RankTrackingTable({ export function RankTrackingTable({
@ -59,6 +69,38 @@ export function RankTrackingTable({
// Only includes rows that are in the current data (respects parent filtering) // Only includes rows that are in the current data (respects parent filtering)
const selectedRows = table.getSelectedRowModel().rows; const selectedRows = table.getSelectedRowModel().rows;
const selectedCount = selectedRows.length; const selectedCount = selectedRows.length;
const selectedRankRows = selectedRows.map((row) => row.original);
const exportSelectionToSheets = () => {
const { headers, rows: exportRows } = buildRankTrackingExport(
selectedRankRows,
showDesktop,
showMobile,
);
void exportTableToSheets({
headers,
rows: exportRows,
feature: "rank_tracking",
});
};
const exportSelectionCsv = () => {
const { headers, rows: exportRows } = buildRankTrackingExport(
selectedRankRows,
showDesktop,
showMobile,
);
const csvRows = exportRows.map((row) =>
row.map((cell, idx) =>
idx === 3 && typeof cell === "number" ? cell.toFixed(2) : cell,
),
);
downloadCsv(
`rank-tracking-${domain}-selected.csv`,
buildCsv(headers, csvRows),
);
captureClientEvent("rank_tracking:export_csv", { scope: "selection" });
};
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (keywordIds: string[]) => mutationFn: (keywordIds: string[]) =>
@ -101,28 +143,35 @@ export function RankTrackingTable({
return ( return (
<> <>
{/* Bulk action bar */} <TableBulkActionBar
{selectedCount > 0 && ( selectedCount={selectedCount}
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm"> onClear={() => table.resetRowSelection()}
<span className="text-base-content/70"> actions={
{selectedCount} keyword <div className="flex items-center px-1.5">
{selectedCount !== 1 ? "s" : ""} selected <TableBulkActionButton
</span> icon={<Trash2 className="size-3.5" />}
<button onClick={() => setShowConfirm(true)}
className="btn btn-error btn-xs gap-1" variant="danger"
onClick={() => setShowConfirm(true)} >
> Remove
<Trash2 className="size-3" /> </TableBulkActionButton>
Remove <TableBulkExportMenu
</button> actions={[
<button {
className="btn btn-ghost btn-xs" label: "Export to Sheets",
onClick={() => table.resetRowSelection()} icon: <Sheet className="size-4" />,
> onClick: exportSelectionToSheets,
Clear },
</button> {
</div> label: "Export CSV",
)} icon: <FileDown className="size-4" />,
onClick: exportSelectionCsv,
},
]}
/>
</div>
}
/>
{/* Confirm modal */} {/* Confirm modal */}
{showConfirm && ( {showConfirm && (

View File

@ -176,7 +176,7 @@ function csvChange(
return previous - current; return previous - current;
} }
function buildRankTrackingExport( export function buildRankTrackingExport(
sorted: RankTrackingRow[], sorted: RankTrackingRow[],
showDesktop: boolean, showDesktop: boolean,
showMobile: boolean, showMobile: boolean,

View File

@ -1,15 +1,9 @@
import { Copy, FileDown, Sheet, Tags, Trash2 } from "lucide-react";
import { import {
ChevronDown, TableBulkActionBar,
Copy, TableBulkActionButton,
Download, TableBulkExportMenu,
FileDown, } from "@/client/components/table/TableBulkActionBar";
Loader2,
Sheet,
Tags,
Trash2,
X,
} from "lucide-react";
import type { ReactNode } from "react";
export function SavedKeywordsBulkActionBar({ export function SavedKeywordsBulkActionBar({
selectedCount, selectedCount,
@ -34,119 +28,52 @@ export function SavedKeywordsBulkActionBar({
const exportBusy = exportingSelection != null; const exportBusy = exportingSelection != null;
return ( return (
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4"> <TableBulkActionBar
<div selectedCount={selectedCount}
role="toolbar" onClear={onClear}
aria-label="Bulk actions" actions={
className="pointer-events-auto flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-300/85 shadow-2xl backdrop-blur" <>
> <div className="flex items-center gap-0.5 px-1.5">
<div className="flex items-center gap-2 border-r border-base-content/10 px-3 py-2 text-sm"> <TableBulkActionButton
<button icon={<Tags className="size-3.5" />}
type="button" onClick={onOpenTags}
aria-label="Clear selection"
className="-ml-1 rounded p-1 text-base-content/55 hover:bg-base-content/10 hover:text-base-content"
onClick={onClear}
>
<X className="size-3.5" />
</button>
<span className="font-medium tabular-nums">{selectedCount}</span>
<span className="text-base-content/60">selected</span>
</div>
<div className="flex items-center gap-0.5 px-1.5">
<ActionButton
icon={<Tags className="size-3.5" />}
onClick={onOpenTags}
>
Tag
</ActionButton>
<div className="dropdown dropdown-top dropdown-end">
<button
type="button"
tabIndex={0}
disabled={exportBusy}
aria-haspopup="menu"
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
> >
{exportBusy ? ( Tag
<Loader2 className="size-3.5 animate-spin" /> </TableBulkActionButton>
) : (
<Download className="size-3.5" /> <TableBulkExportMenu
)} busy={exportBusy}
Export actions={[
<ChevronDown className="size-3 opacity-60" /> {
</button> label: "Copy keywords",
<ul icon: <Copy className="size-4" />,
tabIndex={0} onClick: onCopy,
role="menu" },
className="dropdown-content menu z-10 mb-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg" {
> label: "Export to Sheets",
<li> icon: <Sheet className="size-4" />,
<button type="button" onClick={onCopy}> onClick: onExportSheets,
<Copy className="size-4" /> },
Copy keywords {
</button> label: "Export CSV",
</li> icon: <FileDown className="size-4" />,
<li> onClick: onExportCsv,
<button },
type="button" ]}
onClick={onExportSheets} />
disabled={exportBusy}
>
<Sheet className="size-4" />
Export to Sheets
</button>
</li>
<li>
<button
type="button"
onClick={onExportCsv}
disabled={exportBusy}
>
<FileDown className="size-4" />
Export CSV
</button>
</li>
</ul>
</div> </div>
</div>
<div className="flex items-center border-l border-base-content/10 px-1.5"> <div className="flex items-center border-l border-base-content/10 px-1.5">
<button <TableBulkActionButton
type="button" icon={<Trash2 className="size-3.5" />}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-error hover:bg-error/10" onClick={onDelete}
onClick={onDelete} variant="danger"
> >
<Trash2 className="size-3.5" /> Delete
Delete </TableBulkActionButton>
</button> </div>
</div> </>
</div> }
</div> />
);
}
function ActionButton({
icon,
children,
onClick,
disabled,
}: {
icon: ReactNode;
children: ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
>
{icon}
{children}
</button>
); );
} }