From c72df498e75d336bc226c50f40bb98e006b2e171 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 13 Jun 2026 19:28:48 +0200 Subject: [PATCH] DataGrid list type; Review/Send refinment; User lock; Table actions --- src/api/campaigns.ts | 59 ++- src/components/StatusBadge.tsx | 4 +- src/components/table/DataGrid.tsx | 358 +++++++++++++++++- .../campaigns/AttachmentsDataPage.tsx | 54 ++- src/features/campaigns/CampaignFieldsPage.tsx | 50 ++- .../campaigns/CampaignOverviewPage.tsx | 130 +++++-- src/features/campaigns/CampaignWorkspace.tsx | 26 +- src/features/campaigns/RecipientDataPage.tsx | 46 ++- .../campaigns/ReviewSendDevelopmentPage.tsx | 305 +++++++++++---- .../components/AttachmentRulesOverlay.tsx | 41 +- .../components/LockedVersionNotice.tsx | 173 +++++++-- src/features/campaigns/utils/campaignView.ts | 38 +- src/styles/campaign-workspace.css | 50 ++- src/styles/components.css | 188 +++++++++ src/utils/arrayOrder.ts | 14 + 15 files changed, 1318 insertions(+), 218 deletions(-) create mode 100644 src/utils/arrayOrder.ts diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index f039873..da6f4eb 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -48,6 +48,9 @@ export type CampaignVersionListItem = { published_at?: string | null; locked_at?: string | null; locked_by_user_id?: string | null; + user_lock_state?: "temporary" | "permanent" | null; + user_locked_at?: string | null; + user_locked_by_user_id?: string | null; created_at?: string; updated_at?: string; validation_summary?: Record | null; @@ -186,6 +189,11 @@ export type CampaignMockSendPayload = { check_files?: boolean; }; +export type CampaignReviewStatePayload = { + inspection_complete: boolean; + reviewed_message_keys: string[]; +}; + export async function listCampaigns(settings: ApiSettings): Promise { const response = await apiFetch(settings, "/api/v1/campaigns"); @@ -261,6 +269,36 @@ export async function unlockCampaignVersionValidation( }); } +export async function lockCampaignVersionTemporarily( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, { + method: "POST" + }); +} + +export async function unlockCampaignVersionUserLock( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, { + method: "POST" + }); +} + +export async function lockCampaignVersionPermanently( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, { + method: "POST" + }); +} + export async function updateCampaignVersion( settings: ApiSettings, campaignId: string, @@ -372,8 +410,25 @@ export async function getCampaignSummary(settings: ApiSettings, campaignId: stri return apiFetch(settings, `/api/v1/campaigns/${campaignId}/summary`); } -export async function getCampaignJobs(settings: ApiSettings, campaignId: string): Promise<{ jobs: Record[] }> { - return apiFetch<{ jobs: Record[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs`); +export async function getCampaignJobs( + settings: ApiSettings, + campaignId: string, + versionId?: string +): Promise<{ jobs: Record[] }> { + const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; + return apiFetch<{ jobs: Record[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`); +} + +export async function updateCampaignReviewState( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignReviewStatePayload +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, { + method: "POST", + body: JSON.stringify(payload) + }); } export async function queueCampaign( diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index 40d1485..69efcd3 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -1,3 +1,3 @@ -export default function StatusBadge({ status }: { status: string }) { - return {status}; +export default function StatusBadge({ status, label }: { status: string; label?: string }) { + return {label ?? status}; } diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx index fcec904..14d4f56 100644 --- a/src/components/table/DataGrid.tsx +++ b/src/components/table/DataGrid.tsx @@ -1,9 +1,25 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, X } from "lucide-react"; +import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react"; +import StatusBadge from "../StatusBadge"; export type DataGridSortDirection = "asc" | "desc"; -export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date"; +export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list"; + +export type DataGridListOption = { + value: string; + label: string; + disabled?: boolean; +}; + +export type DataGridListConfig = { + options: DataGridListOption[]; + display?: "text" | "pill"; + editable?: boolean; + onChange?: (row: T, index: number, value: string) => void; + optionsEditable?: boolean; + onOptionsChange?: (options: DataGridListOption[]) => void; +}; type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after"; @@ -17,6 +33,8 @@ export type DataGridColumn = { sortable?: boolean; filterable?: boolean; filterType?: DataGridFilterType; + columnType?: "default" | "from-list"; + list?: DataGridListConfig; sticky?: "start" | "end"; align?: "left" | "center" | "right"; className?: string; @@ -44,6 +62,20 @@ type DataGridProps = { className?: string; rowClassName?: (row: T, index: number) => string | undefined; storageKey?: string; + initialFilters?: Record; +}; + +export type DataGridRowActionsProps = { + disabled?: boolean; + removeDisabled?: boolean; + onAddBelow: () => void; + onRemove: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + addLabel?: string; + removeLabel?: string; + moveUpLabel?: string; + moveDownLabel?: string; }; type FilterPosition = { @@ -65,10 +97,13 @@ export default function DataGrid({ fit = "container", className = "", rowClassName, - storageKey + storageKey, + initialFilters = {} }: DataGridProps) { const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`; - const [state, setState] = useState(() => loadState(localStorageKey)); + const initialFiltersKey = JSON.stringify(initialFilters); + const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]); + const [state, setState] = useState(() => mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters)); const [resizeState, setResizeState] = useState<{ columnId: string; startX: number; @@ -93,6 +128,10 @@ export default function DataGrid({ setState((current) => sanitizePersistedColumnState(columns, current)); }, [columns]); + useEffect(() => { + setState((current) => mergeInitialFilters(current, normalizedInitialFilters)); + }, [normalizedInitialFilters]); + useEffect(() => { try { window.localStorage.setItem(localStorageKey, JSON.stringify(state)); @@ -247,7 +286,9 @@ export default function DataGrid({ const filterTypes = useMemo(() => { const result: Record = {}; - for (const column of columns) result[column.id] = column.filterType ?? inferFilterType(column, rows); + for (const column of columns) { + result[column.id] = column.columnType === "from-list" ? "list" : column.filterType ?? inferFilterType(column, rows); + } return result; }, [columns, rows]); @@ -301,10 +342,13 @@ export default function DataGrid({ setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } })); } - function clearFilter(columnId: string) { + function clearFilter(columnId: string, defaults: Record) { setState((current) => { const nextFilters = { ...(current.filters ?? {}) }; - delete nextFilters[columnId]; + // Keep an explicit empty value when the column has an initial filter so + // clearing it remains a user choice after remounting the grid. + if (Object.prototype.hasOwnProperty.call(defaults, columnId)) nextFilters[columnId] = ""; + else delete nextFilters[columnId]; return { ...current, filters: nextFilters }; }); } @@ -422,7 +466,7 @@ export default function DataGrid({ className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()} style={stickyStyle(column, stickyOffsets[columnIndex])} > - {column.render ? column.render(row, originalIndex) : stringifyCell(column.value?.(row, originalIndex))} + {renderCell(column, row, originalIndex)} )); })} @@ -433,9 +477,13 @@ export default function DataGrid({ column={activeFilterColumn} filterType={filterTypes[activeFilterColumn.id]} value={state.filters?.[activeFilterColumn.id] ?? ""} + listOptions={listOptionsForColumn(activeFilterColumn, rows)} + listDisplay={activeFilterColumn.list?.display ?? "text"} + optionsEditable={Boolean(activeFilterColumn.list?.optionsEditable && activeFilterColumn.list?.onOptionsChange)} position={filterPosition} onChange={(value) => patchFilter(activeFilterColumn.id, value)} - onClear={() => clearFilter(activeFilterColumn.id)} + onClear={() => clearFilter(activeFilterColumn.id, normalizedInitialFilters)} + onOptionsChange={activeFilterColumn.list?.onOptionsChange} onClose={() => setOpenFilterColumnId(null)} />, document.body @@ -444,18 +492,84 @@ export default function DataGrid({ ); } +export function DataGridRowActions({ + disabled = false, + removeDisabled = false, + onAddBelow, + onRemove, + onMoveUp, + onMoveDown, + addLabel = "Add row below", + removeLabel = "Remove row", + moveUpLabel = "Move row up", + moveDownLabel = "Move row down" +}: DataGridRowActionsProps) { + return ( +
+ + + + +
+ ); +} + type FilterPopoverProps = { column: Pick, "id" | "header">; filterType: DataGridFilterType; value: string; + listOptions: DataGridListOption[]; + listDisplay: "text" | "pill"; + optionsEditable: boolean; position: FilterPosition; onChange: (value: string) => void; onClear: () => void; + onOptionsChange?: (options: DataGridListOption[]) => void; onClose: () => void; }; const FilterPopover = forwardRef(function FilterPopover( - { column, filterType, value, position, onChange, onClear, onClose }, + { column, filterType, value, listOptions, listDisplay, optionsEditable, position, onChange, onClear, onOptionsChange, onClose }, ref ) { const parsed = parseTypedFilter(value, filterType); @@ -472,15 +586,23 @@ const FilterPopover = forwardRef(function Fi Filter {column.header} - {filterType === "boolean" ? ( - + {filterType === "list" ? ( + + ) : filterType === "boolean" ? ( + ) : filterType === "number" || filterType === "integer" || filterType === "date" ? (