Locking refinement; Review/Send improvement
This commit is contained in:
@@ -2,6 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
||||||
import StatusBadge from "../StatusBadge";
|
import StatusBadge from "../StatusBadge";
|
||||||
|
import Button from "../Button";
|
||||||
|
|
||||||
export type DataGridSortDirection = "asc" | "desc";
|
export type DataGridSortDirection = "asc" | "desc";
|
||||||
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
|
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
|
||||||
@@ -58,6 +59,9 @@ type DataGridProps<T> = {
|
|||||||
columns: DataGridColumn<T>[];
|
columns: DataGridColumn<T>[];
|
||||||
getRowKey: (row: T, index: number) => string;
|
getRowKey: (row: T, index: number) => string;
|
||||||
emptyText?: ReactNode;
|
emptyText?: ReactNode;
|
||||||
|
filteredEmptyText?: ReactNode;
|
||||||
|
emptyAction?: ReactNode;
|
||||||
|
emptyActionColumnId?: string;
|
||||||
fit?: "content" | "container";
|
fit?: "content" | "container";
|
||||||
className?: string;
|
className?: string;
|
||||||
rowClassName?: (row: T, index: number) => string | undefined;
|
rowClassName?: (row: T, index: number) => string | undefined;
|
||||||
@@ -84,9 +88,19 @@ type FilterPosition = {
|
|||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResizeTarget = {
|
||||||
|
columnId: string;
|
||||||
|
startWidth: number;
|
||||||
|
maxWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
const STORAGE_PREFIX = "multimailer.datagrid.";
|
const STORAGE_PREFIX = "multimailer.datagrid.";
|
||||||
const FILTER_POPOVER_WIDTH = 320;
|
const FILTER_POPOVER_WIDTH = 320;
|
||||||
const FILTER_POPOVER_MARGIN = 12;
|
const FILTER_POPOVER_MARGIN = 12;
|
||||||
|
const MIN_HEADER_LABEL_WIDTH = 72;
|
||||||
|
const SORT_CONTROL_RESERVE = 24;
|
||||||
|
const FILTER_CONTROL_RESERVE = 32;
|
||||||
|
const RESIZE_CONTROL_RESERVE = 20;
|
||||||
|
|
||||||
export default function DataGrid<T>({
|
export default function DataGrid<T>({
|
||||||
id,
|
id,
|
||||||
@@ -94,6 +108,9 @@ export default function DataGrid<T>({
|
|||||||
columns,
|
columns,
|
||||||
getRowKey,
|
getRowKey,
|
||||||
emptyText = "No rows found.",
|
emptyText = "No rows found.",
|
||||||
|
filteredEmptyText = "Filter yields an empty result.",
|
||||||
|
emptyAction,
|
||||||
|
emptyActionColumnId = "actions",
|
||||||
fit = "container",
|
fit = "container",
|
||||||
className = "",
|
className = "",
|
||||||
rowClassName,
|
rowClassName,
|
||||||
@@ -109,9 +126,10 @@ export default function DataGrid<T>({
|
|||||||
startX: number;
|
startX: number;
|
||||||
startWidth: number;
|
startWidth: number;
|
||||||
baseWidths: Record<string, number>;
|
baseWidths: Record<string, number>;
|
||||||
immediateRightColumnId?: string;
|
rightResizeTargets: ResizeTarget[];
|
||||||
immediateRightStartWidth?: number;
|
|
||||||
shrinkRoomWithoutScroll: number;
|
shrinkRoomWithoutScroll: number;
|
||||||
|
actualLastColumnMaxShrink: number;
|
||||||
|
isActualLastColumn: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
||||||
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
||||||
@@ -193,39 +211,49 @@ export default function DataGrid<T>({
|
|||||||
const column = columns.find((item) => item.id === activeResize.columnId);
|
const column = columns.find((item) => item.id === activeResize.columnId);
|
||||||
if (!column) return;
|
if (!column) return;
|
||||||
|
|
||||||
const minWidth = column.minWidth ?? 80;
|
const minWidth = effectiveColumnMinWidth(column);
|
||||||
const maxWidth = column.maxWidth ?? 2000;
|
const maxWidth = Math.max(minWidth, column.maxWidth ?? 2000);
|
||||||
const rawDelta = event.clientX - activeResize.startX;
|
const rawDelta = event.clientX - activeResize.startX;
|
||||||
const activeDelta = Math.min(
|
const activeDelta = Math.min(
|
||||||
maxWidth - activeResize.startWidth,
|
maxWidth - activeResize.startWidth,
|
||||||
Math.max(minWidth - activeResize.startWidth, rawDelta)
|
Math.max(minWidth - activeResize.startWidth, rawDelta)
|
||||||
);
|
);
|
||||||
const nextWidths = { ...activeResize.baseWidths };
|
const nextWidths = { ...activeResize.baseWidths };
|
||||||
let fillColumnId: string | null | undefined;
|
let fillColumnId: string | null | undefined = null;
|
||||||
|
|
||||||
if (activeDelta > 0 && activeResize.immediateRightColumnId) {
|
if (activeResize.isActualLastColumn) {
|
||||||
// A growing column may only shrink its immediate neighbour. Once that
|
// The resizable actual final column has no columns to its right to
|
||||||
// neighbour reaches its minimum, the grid grows and can scroll rather
|
// compensate. It may grow freely, and it may shrink by the amount of
|
||||||
// than pulling space from a more distant column.
|
// horizontal overflow measured at drag start, but never far enough to
|
||||||
const neighbour = columns.find((item) => item.id === activeResize.immediateRightColumnId);
|
// leave unused space on the right.
|
||||||
const neighbourStartWidth = activeResize.immediateRightStartWidth ?? 0;
|
const minimumDelta = -activeResize.actualLastColumnMaxShrink;
|
||||||
const neighbourMinWidth = neighbour?.minWidth ?? 80;
|
const clampedLastColumnDelta = Math.max(minimumDelta, activeDelta);
|
||||||
const absorbedDelta = Math.min(activeDelta, Math.max(0, neighbourStartWidth - neighbourMinWidth));
|
nextWidths[activeResize.columnId] = activeResize.startWidth + clampedLastColumnDelta;
|
||||||
|
} else if (activeDelta >= 0) {
|
||||||
|
// Growing a non-final column changes only the dragged column. Any
|
||||||
|
// compensation introduced while the pointer was left of its drag-start
|
||||||
|
// position disappears automatically because each move is recalculated
|
||||||
|
// from the drag-start widths. Untouched columns are never shrunk merely
|
||||||
|
// to make room for growth.
|
||||||
nextWidths[activeResize.columnId] = activeResize.startWidth + activeDelta;
|
nextWidths[activeResize.columnId] = activeResize.startWidth + activeDelta;
|
||||||
nextWidths[activeResize.immediateRightColumnId] = neighbourStartWidth - absorbedDelta;
|
|
||||||
fillColumnId = activeResize.immediateRightColumnId;
|
|
||||||
} else {
|
} else {
|
||||||
// Shrinking never resizes another column. It consumes only horizontal
|
const requestedShrink = -activeDelta;
|
||||||
// overflow that is still available to the right of the viewport. Once
|
const freeShrink = Math.min(requestedShrink, activeResize.shrinkRoomWithoutScroll);
|
||||||
// that room is exhausted, resizing stops instead of growing a buffer
|
const compensationNeeded = requestedShrink - freeShrink;
|
||||||
// column, reclaiming width from the left, or shifting the scroll view.
|
|
||||||
const boundedDelta = activeDelta < 0
|
// Shrinking first consumes only overflow that is still to the right of
|
||||||
? Math.max(activeDelta, -activeResize.shrinkRoomWithoutScroll)
|
// the current viewport. Once that room is exhausted, all eligible
|
||||||
: activeDelta;
|
// resizable, non-sticky columns to the right grow evenly so the grid
|
||||||
nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta;
|
// continues to fill its container. Targets at maxWidth drop out and the
|
||||||
// Exact pixel tracks remain fixed during the drag, so no implicit
|
// remainder is redistributed. Recomputing from drag-start widths makes
|
||||||
// flexible filler can change a different column behind the pointer.
|
// the operation exactly reversible when the pointer changes direction.
|
||||||
fillColumnId = null;
|
const distribution = distributeGrowthAmount(
|
||||||
|
compensationNeeded,
|
||||||
|
activeResize.rightResizeTargets
|
||||||
|
);
|
||||||
|
const appliedShrink = freeShrink + distribution.applied;
|
||||||
|
nextWidths[activeResize.columnId] = activeResize.startWidth - appliedShrink;
|
||||||
|
applyGrowthDistribution(nextWidths, activeResize.rightResizeTargets, distribution.amounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
@@ -328,6 +356,7 @@ export default function DataGrid<T>({
|
|||||||
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
|
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
|
||||||
const gridClassName = `data-grid ${hasFlexibleColumns ? "data-grid-has-flex" : "data-grid-fixed-only"}`;
|
const gridClassName = `data-grid ${hasFlexibleColumns ? "data-grid-has-flex" : "data-grid-fixed-only"}`;
|
||||||
const activeFilterColumn = openFilterColumnId ? columns.find((column) => column.id === openFilterColumnId) : undefined;
|
const activeFilterColumn = openFilterColumnId ? columns.find((column) => column.id === openFilterColumnId) : undefined;
|
||||||
|
const hasActiveFilters = columns.some((column) => Boolean((state.filters?.[column.id] ?? "").trim()));
|
||||||
|
|
||||||
function toggleSort(column: DataGridColumn<T>) {
|
function toggleSort(column: DataGridColumn<T>) {
|
||||||
if (!column.sortable) return;
|
if (!column.sortable) return;
|
||||||
@@ -418,13 +447,25 @@ export default function DataGrid<T>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
|
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
|
||||||
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
|
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
|
||||||
const immediateRightColumn = chooseImmediateResizePartner(columns, column.id);
|
const activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
|
||||||
const immediateRightStartWidth = immediateRightColumn
|
const isActualLastColumn = activeColumnIndex === columns.length - 1;
|
||||||
? baseWidths[immediateRightColumn.id] ?? columnPixelWidth(immediateRightColumn, state.widths?.[immediateRightColumn.id], measuredWidths[immediateRightColumn.id])
|
const rightResizeTargets = columns
|
||||||
: undefined;
|
.slice(activeColumnIndex + 1)
|
||||||
|
.filter((candidate) => candidate.resizable && !candidate.sticky)
|
||||||
|
.map((candidate) => ({
|
||||||
|
columnId: candidate.id,
|
||||||
|
startWidth: baseWidths[candidate.id] ?? columnPixelWidth(candidate, state.widths?.[candidate.id], measuredWidths[candidate.id]),
|
||||||
|
maxWidth: Math.max(effectiveColumnMinWidth(candidate), candidate.maxWidth ?? 2000)
|
||||||
|
}));
|
||||||
const gridElement = gridRef.current;
|
const gridElement = gridRef.current;
|
||||||
|
const totalHorizontalOverflow = gridElement
|
||||||
|
? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth)
|
||||||
|
: 0;
|
||||||
const shrinkRoomWithoutScroll = gridElement
|
const shrinkRoomWithoutScroll = gridElement
|
||||||
? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth - gridElement.scrollLeft)
|
? Math.max(0, totalHorizontalOverflow - gridElement.scrollLeft)
|
||||||
|
: 0;
|
||||||
|
const actualLastColumnMaxShrink = isActualLastColumn
|
||||||
|
? Math.min(totalHorizontalOverflow, Math.max(0, currentWidth - effectiveColumnMinWidth(column)))
|
||||||
: 0;
|
: 0;
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -438,9 +479,10 @@ export default function DataGrid<T>({
|
|||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startWidth: currentWidth,
|
startWidth: currentWidth,
|
||||||
baseWidths,
|
baseWidths,
|
||||||
immediateRightColumnId: immediateRightColumn?.id,
|
rightResizeTargets,
|
||||||
immediateRightStartWidth,
|
shrinkRoomWithoutScroll,
|
||||||
shrinkRoomWithoutScroll
|
actualLastColumnMaxShrink,
|
||||||
|
isActualLastColumn
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -450,11 +492,36 @@ export default function DataGrid<T>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleRows.length === 0 ? (
|
{visibleRows.length === 0 ? (() => {
|
||||||
<div className="data-grid-empty" role="row">
|
const filteredEmpty = rows.length > 0 && hasActiveFilters;
|
||||||
<div role="cell">{emptyText}</div>
|
const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
|
||||||
</div>
|
const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
|
||||||
) : visibleRows.map((row, visibleIndex) => {
|
if (!filteredEmpty && emptyAction && actionColumn && actionColumnIndex > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="data-grid-cell data-grid-body-cell data-grid-empty-message data-grid-row-even is-last-row"
|
||||||
|
role="cell"
|
||||||
|
style={{ gridColumn: `1 / ${actionColumnIndex + 1}` }}
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`data-grid-cell data-grid-body-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
|
||||||
|
role="cell"
|
||||||
|
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}
|
||||||
|
>
|
||||||
|
{emptyAction}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="data-grid-empty" role="row">
|
||||||
|
<div role="cell">{filteredEmpty ? filteredEmptyText : emptyText}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : visibleRows.map((row, visibleIndex) => {
|
||||||
const originalIndex = rows.indexOf(row);
|
const originalIndex = rows.indexOf(row);
|
||||||
const rowClass = rowClassName?.(row, originalIndex);
|
const rowClass = rowClassName?.(row, originalIndex);
|
||||||
const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd";
|
const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd";
|
||||||
@@ -506,50 +573,71 @@ export function DataGridRowActions({
|
|||||||
}: DataGridRowActionsProps) {
|
}: DataGridRowActionsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="data-grid-row-actions">
|
<div className="data-grid-row-actions">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="primary"
|
||||||
className="data-grid-row-action is-add"
|
className="data-grid-row-action is-add"
|
||||||
aria-label={addLabel}
|
aria-label={addLabel}
|
||||||
title={addLabel}
|
title={addLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onAddBelow}
|
onClick={onAddBelow}
|
||||||
>
|
>
|
||||||
<Plus size={15} aria-hidden="true" />
|
<Plus size={16} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
className={`data-grid-row-action is-reorder${onMoveUp ? "" : " is-placeholder"}`}
|
className="data-grid-row-action is-reorder"
|
||||||
aria-label={moveUpLabel}
|
aria-label={moveUpLabel}
|
||||||
title={moveUpLabel}
|
title={moveUpLabel}
|
||||||
aria-hidden={!onMoveUp}
|
|
||||||
tabIndex={onMoveUp ? 0 : -1}
|
|
||||||
disabled={disabled || !onMoveUp}
|
disabled={disabled || !onMoveUp}
|
||||||
onClick={onMoveUp}
|
onClick={() => onMoveUp?.()}
|
||||||
>
|
>
|
||||||
<ArrowUp size={15} aria-hidden="true" />
|
<ArrowUp size={16} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
className={`data-grid-row-action is-reorder${onMoveDown ? "" : " is-placeholder"}`}
|
className="data-grid-row-action is-reorder"
|
||||||
aria-label={moveDownLabel}
|
aria-label={moveDownLabel}
|
||||||
title={moveDownLabel}
|
title={moveDownLabel}
|
||||||
aria-hidden={!onMoveDown}
|
|
||||||
tabIndex={onMoveDown ? 0 : -1}
|
|
||||||
disabled={disabled || !onMoveDown}
|
disabled={disabled || !onMoveDown}
|
||||||
onClick={onMoveDown}
|
onClick={() => onMoveDown?.()}
|
||||||
>
|
>
|
||||||
<ArrowDown size={15} aria-hidden="true" />
|
<ArrowDown size={16} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="danger"
|
||||||
className="data-grid-row-action is-remove"
|
className="data-grid-row-action is-remove"
|
||||||
aria-label={removeLabel}
|
aria-label={removeLabel}
|
||||||
title={removeLabel}
|
title={removeLabel}
|
||||||
disabled={disabled || removeDisabled}
|
disabled={disabled || removeDisabled}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
<Trash2 size={15} aria-hidden="true" />
|
<Trash2 size={16} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGridEmptyAction({
|
||||||
|
disabled = false,
|
||||||
|
onAdd,
|
||||||
|
label = "Add first row"
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="data-grid-row-action is-add"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -841,33 +929,65 @@ function humanizeListValue(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePersistedColumnState<T>(columns: DataGridColumn<T>[], state: DataGridState): DataGridState {
|
function sanitizePersistedColumnState<T>(columns: DataGridColumn<T>[], state: DataGridState): DataGridState {
|
||||||
const resizableColumnIds = new Set(columns.filter((column) => column.resizable).map((column) => column.id));
|
const resizableColumns = new Map(columns.filter((column) => column.resizable).map((column) => [column.id, column]));
|
||||||
const nextWidths = Object.fromEntries(
|
const nextWidths = Object.fromEntries(
|
||||||
Object.entries(state.widths ?? {}).filter(([columnId]) => resizableColumnIds.has(columnId))
|
Object.entries(state.widths ?? {}).flatMap(([columnId, width]) => {
|
||||||
|
const column = resizableColumns.get(columnId);
|
||||||
|
if (!column || !Number.isFinite(width)) return [];
|
||||||
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
|
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||||
|
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const fillColumn = state.fillColumnId
|
const hasSavedWidths = Object.keys(nextWidths).length > 0;
|
||||||
|
const fillColumn = typeof state.fillColumnId === "string"
|
||||||
? columns.find((column) => column.id === state.fillColumnId)
|
? columns.find((column) => column.id === state.fillColumnId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextFillColumnId = fillColumn?.resizable && !fillColumn.sticky ? fillColumn.id : undefined;
|
const shouldPreserveExactWidths = hasSavedWidths && (state.fillColumnId === null || state.fillColumnId === undefined);
|
||||||
const widthsChanged = Object.keys(nextWidths).length !== Object.keys(state.widths ?? {}).length;
|
const nextFillColumnId = shouldPreserveExactWidths
|
||||||
|
? null
|
||||||
|
: fillColumn?.resizable && !fillColumn.sticky
|
||||||
|
? fillColumn.id
|
||||||
|
: undefined;
|
||||||
|
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, nextWidths);
|
||||||
const fillChanged = nextFillColumnId !== state.fillColumnId;
|
const fillChanged = nextFillColumnId !== state.fillColumnId;
|
||||||
if (!widthsChanged && !fillChanged) return state;
|
if (!widthsChanged && !fillChanged) return state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
widths: Object.keys(nextWidths).length > 0 ? nextWidths : undefined,
|
widths: hasSavedWidths ? nextWidths : undefined,
|
||||||
fillColumnId: nextFillColumnId
|
fillColumnId: nextFillColumnId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number, stretch = false): string {
|
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number, stretch = false): string {
|
||||||
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
|
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||||
if (stretch) {
|
if (stretch) {
|
||||||
const baseWidth = savedWidth ?? fixedWidthFloor(column);
|
const baseWidth = Math.min(maximum, Math.max(minimum, savedWidth ?? fixedWidthFloor(column)));
|
||||||
return `minmax(${baseWidth}px, 1fr)`;
|
return `minmax(${baseWidth}px, 1fr)`;
|
||||||
}
|
}
|
||||||
if (savedWidth) return `${savedWidth}px`;
|
if (savedWidth) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
|
||||||
if (typeof column.width === "number") return `${column.width}px`;
|
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
||||||
if (column.width) return column.width;
|
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
||||||
return `minmax(${column.minWidth ?? 140}px, 1fr)`;
|
return `minmax(${minimum}px, 1fr)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnTrackWithMinimum(width: string, minimum: number): string {
|
||||||
|
const normalized = width.trim();
|
||||||
|
const minmaxMatch = normalized.match(/^minmax\(\s*([^,]+),\s*(.+)\)$/i);
|
||||||
|
if (minmaxMatch) {
|
||||||
|
const parsedMinimum = parsePixelWidth(minmaxMatch[1]);
|
||||||
|
const minimumTrack = parsedMinimum === null ? `${minimum}px` : `${Math.max(minimum, parsedMinimum)}px`;
|
||||||
|
return `minmax(${minimumTrack}, ${minmaxMatch[2]})`;
|
||||||
|
}
|
||||||
|
const parsed = parsePixelWidth(normalized);
|
||||||
|
if (parsed !== null && /^\s*\d+(?:\.\d+)?px\s*$/i.test(normalized)) {
|
||||||
|
return `${Math.max(minimum, parsed)}px`;
|
||||||
|
}
|
||||||
|
if (normalized === "auto" || normalized.includes("fr")) {
|
||||||
|
return `minmax(${minimum}px, ${normalized})`;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseStretchedColumns<T>(
|
function chooseStretchedColumns<T>(
|
||||||
@@ -913,11 +1033,62 @@ function chooseLayoutFillColumn<T>(columns: DataGridColumn<T>[]): string | undef
|
|||||||
return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id;
|
return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseImmediateResizePartner<T>(columns: DataGridColumn<T>[], activeColumnId: string): DataGridColumn<T> | undefined {
|
function effectiveColumnMinWidth<T>(column: DataGridColumn<T>): number {
|
||||||
const activeIndex = columns.findIndex((column) => column.id === activeColumnId);
|
const affordanceWidth = MIN_HEADER_LABEL_WIDTH
|
||||||
if (activeIndex < 0) return undefined;
|
+ (column.sortable ? SORT_CONTROL_RESERVE : 0)
|
||||||
const neighbour = columns[activeIndex + 1];
|
+ (column.filterable ? FILTER_CONTROL_RESERVE : 0)
|
||||||
return neighbour?.resizable && !neighbour.sticky ? neighbour : undefined;
|
+ (column.resizable ? RESIZE_CONTROL_RESERVE : 0);
|
||||||
|
return Math.max(column.minWidth ?? 0, affordanceWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distributeGrowthAmount(
|
||||||
|
requestedAmount: number,
|
||||||
|
targets: ResizeTarget[]
|
||||||
|
): { applied: number; amounts: Record<string, number> } {
|
||||||
|
const amounts: Record<string, number> = Object.fromEntries(targets.map((target) => [target.columnId, 0]));
|
||||||
|
let remaining = Math.max(0, requestedAmount);
|
||||||
|
let activeTargets = targets.filter((target) => growthCapacity(target) > 0.01);
|
||||||
|
|
||||||
|
// Water-fill the available capacity: every eligible target receives the same
|
||||||
|
// share until it reaches maxWidth, then the remainder is redistributed among
|
||||||
|
// the targets that still have room.
|
||||||
|
while (remaining > 0.01 && activeTargets.length > 0) {
|
||||||
|
const share = remaining / activeTargets.length;
|
||||||
|
let appliedThisRound = 0;
|
||||||
|
const nextTargets: ResizeTarget[] = [];
|
||||||
|
|
||||||
|
for (const target of activeTargets) {
|
||||||
|
const used = amounts[target.columnId] ?? 0;
|
||||||
|
const available = Math.max(0, growthCapacity(target) - used);
|
||||||
|
const applied = Math.min(share, available);
|
||||||
|
amounts[target.columnId] = used + applied;
|
||||||
|
appliedThisRound += applied;
|
||||||
|
if (available - applied > 0.01) nextTargets.push(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedThisRound <= 0.01) break;
|
||||||
|
remaining -= appliedThisRound;
|
||||||
|
activeTargets = nextTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
applied: Math.max(0, requestedAmount - remaining),
|
||||||
|
amounts
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function growthCapacity(target: ResizeTarget): number {
|
||||||
|
return Math.max(0, target.maxWidth - target.startWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGrowthDistribution(
|
||||||
|
widths: Record<string, number>,
|
||||||
|
targets: ResizeTarget[],
|
||||||
|
amounts: Record<string, number>
|
||||||
|
): void {
|
||||||
|
for (const target of targets) {
|
||||||
|
widths[target.columnId] = target.startWidth + (amounts[target.columnId] ?? 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function measuredColumnWidths<T>(
|
function measuredColumnWidths<T>(
|
||||||
@@ -936,9 +1107,11 @@ function measuredColumnWidths<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fixedWidthFloor<T>(column: DataGridColumn<T>): number {
|
function fixedWidthFloor<T>(column: DataGridColumn<T>): number {
|
||||||
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
|
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||||
const parsed = parsePixelWidth(column.width);
|
const parsed = parsePixelWidth(column.width);
|
||||||
if (typeof column.width === "number") return column.width;
|
const configured = typeof column.width === "number" ? column.width : parsed ?? minimum;
|
||||||
return parsed ?? column.minWidth ?? 140;
|
return Math.min(maximum, Math.max(minimum, configured));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean {
|
function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean {
|
||||||
@@ -954,12 +1127,13 @@ function isFlexibleWidth(width?: string | number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
|
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
|
||||||
if (measuredWidth) return measuredWidth;
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
if (savedWidth) return savedWidth;
|
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||||
if (typeof column.width === "number") return column.width;
|
const configured = measuredWidth
|
||||||
const parsed = parsePixelWidth(column.width);
|
?? savedWidth
|
||||||
if (parsed) return parsed;
|
?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))
|
||||||
return column.minWidth ?? 160;
|
?? minimum;
|
||||||
|
return Math.min(maximum, Math.max(minimum, configured));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePixelWidth(width?: string | number): number | null {
|
function parsePixelWidth(width?: string | number): number | null {
|
||||||
|
|||||||
@@ -9,24 +9,27 @@ import LockedVersionNotice from "./components/LockedVersionNotice";
|
|||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import ManagedFileChooser from "./components/ManagedFileChooser";
|
import ManagedFileChooser from "./components/ManagedFileChooser";
|
||||||
import { countIndividualAttachmentRules, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||||
|
|
||||||
type PathChooserState = { index: number };
|
type PathChooserState = { index: number };
|
||||||
|
type IndividualDisableState = { index: number; usageCount: number };
|
||||||
|
|
||||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||||
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
||||||
|
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -79,6 +82,40 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
|
patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setIndividualEligibility(index: number, checked: boolean) {
|
||||||
|
if (locked) return;
|
||||||
|
if (checked) {
|
||||||
|
patchBasePath(index, { allow_individual: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const basePath = basePaths[index];
|
||||||
|
if (!basePath) return;
|
||||||
|
const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath);
|
||||||
|
if (usageCount > 0) {
|
||||||
|
setIndividualDisable({ index, usageCount });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchBasePath(index, { allow_individual: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmIndividualDisable() {
|
||||||
|
if (!individualDisable) return;
|
||||||
|
const basePath = basePaths[individualDisable.index];
|
||||||
|
if (!basePath) {
|
||||||
|
setIndividualDisable(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item);
|
||||||
|
setDraft((current) => {
|
||||||
|
const source = current ?? {};
|
||||||
|
const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths);
|
||||||
|
const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || ".");
|
||||||
|
return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath));
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
setIndividualDisable(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +134,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing attachments." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
@@ -105,9 +142,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
rows={basePaths}
|
rows={basePaths}
|
||||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||||
getRowKey={(basePath) => basePath.id}
|
getRowKey={(basePath) => basePath.id}
|
||||||
emptyText={<div className="data-grid-empty-action"><span>No attachment sources configured.</span><Button variant="primary" onClick={() => addBasePath(-1)} disabled={locked}>Add attachment source</Button></div>}
|
emptyText="No attachment sources configured."
|
||||||
|
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
|
||||||
className="attachment-sources-table-wrap attachment-sources-table"
|
className="attachment-sources-table-wrap attachment-sources-table"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -164,6 +202,20 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(individualDisable)}
|
||||||
|
title="Disable individual attachments for this source?"
|
||||||
|
message={individualDisable
|
||||||
|
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
|
||||||
|
: ""}
|
||||||
|
confirmLabel="Remove individual attachments"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
tone="danger"
|
||||||
|
onConfirm={confirmIndividualDisable}
|
||||||
|
onCancel={() => setIndividualDisable(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,13 +225,14 @@ type AttachmentSourceColumnContext = {
|
|||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
fileSpaces: FileSpace[];
|
fileSpaces: FileSpace[];
|
||||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||||
|
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||||
addBasePath: (afterIndex?: number) => void;
|
addBasePath: (afterIndex?: number) => void;
|
||||||
moveBasePath: (index: number, targetIndex: number) => void;
|
moveBasePath: (index: number, targetIndex: number) => void;
|
||||||
removeBasePath: (index: number) => void;
|
removeBasePath: (index: number) => void;
|
||||||
setPathChooser: (state: PathChooserState | null) => void;
|
setPathChooser: (state: PathChooserState | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||||
{
|
{
|
||||||
@@ -211,12 +264,12 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
|
|||||||
),
|
),
|
||||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||||
},
|
},
|
||||||
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
||||||
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
width: 150,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_basePath, index) => (
|
render: (_basePath, index) => (
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
|
|||||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
const fieldValueKeys = useRef<string[]>([]);
|
const fieldValueKeys = useRef<string[]>([]);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -167,7 +167,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing fields." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
@@ -177,7 +177,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
rows={fields}
|
rows={fields}
|
||||||
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
||||||
getRowKey={(_field, index) => `field-row-${index}`}
|
getRowKey={(_field, index) => `field-row-${index}`}
|
||||||
emptyText={<div className="data-grid-empty-action"><span>No campaign fields configured yet.</span><Button variant="primary" onClick={() => addField(-1)} disabled={locked}>Add field</Button></div>}
|
emptyText="No campaign fields configured yet."
|
||||||
|
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
|
||||||
className="field-editor-table-wrap field-editor-table"
|
className="field-editor-table-wrap field-editor-table"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -215,7 +216,7 @@ function fieldColumns({ locked, fields, globalValues, renameField, setField, set
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
width: 150,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_field, index) => (
|
render: (_field, index) => (
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-versions`}
|
id={`campaign-${campaignId}-versions`}
|
||||||
rows={versions}
|
rows={versions}
|
||||||
columns={versionColumns(setPendingLockAction)}
|
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
||||||
getRowKey={(version) => version.id}
|
getRowKey={(version) => version.id}
|
||||||
emptyText="No versions found."
|
emptyText="No versions found."
|
||||||
className="version-history-table"
|
className="version-history-table"
|
||||||
@@ -187,11 +187,11 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void): DataGridColumn<CampaignVersionListItem>[] {
|
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||||
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||||
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel },
|
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
|
||||||
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, render: validationLabel, value: validationLabel },
|
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, render: validationLabel, value: validationLabel },
|
||||||
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, render: buildLabel, value: buildLabel },
|
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, render: buildLabel, value: buildLabel },
|
||||||
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
||||||
@@ -200,24 +200,28 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
|||||||
header: "Actions",
|
header: "Actions",
|
||||||
width: 310,
|
width: 310,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (version) => (
|
render: (version) => {
|
||||||
<div className="button-row compact-actions">
|
const isCurrent = version.id === currentVersionId;
|
||||||
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link>
|
return (
|
||||||
{isTemporaryUserLockedVersion(version) ? (
|
<div className="button-row compact-actions">
|
||||||
<>
|
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
|
||||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
|
||||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
<>
|
||||||
</>
|
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
||||||
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
||||||
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
|
</>
|
||||||
) : null}
|
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
||||||
</div>
|
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
|
||||||
),
|
) : null)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionLockLabel(version: CampaignVersionListItem): string {
|
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
||||||
|
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
|
||||||
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
|
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
|
||||||
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
||||||
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
|||||||
const [editorState, setEditorState] = useState<EditorState>({});
|
const [editorState, setEditorState] = useState<EditorState>({});
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -70,7 +70,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing global settings." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
|
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -263,7 +263,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing server settings." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import VersionLine from "./components/VersionLine";
|
|||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
@@ -42,7 +42,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -144,7 +144,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing sender or recipient profiles." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
@@ -239,7 +239,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
rows={inlineEntries.slice(0, 100)}
|
rows={inlineEntries.slice(0, 100)}
|
||||||
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry })}
|
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry })}
|
||||||
getRowKey={(entry, index) => String(entry.id || index)}
|
getRowKey={(entry, index) => String(entry.id || index)}
|
||||||
emptyText={<div className="data-grid-empty-action"><span>No recipient profiles are stored in the current version yet.</span><Button variant="primary" onClick={() => addRecipient(-1)} disabled={locked}>Add recipient</Button></div>}
|
emptyText="No recipient profiles are stored in the current version yet."
|
||||||
|
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
|
||||||
className="recipient-table-wrap recipient-address-table"
|
className="recipient-table-wrap recipient-address-table"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -293,7 +294,7 @@ function recipientProfileColumns({ locked, entries, entryAddressColumns, address
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
width: 150,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_entry, index) => (
|
render: (_entry, index) => (
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -83,7 +83,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing recipient data." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MailSearch,
|
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Send,
|
Send,
|
||||||
@@ -44,6 +41,7 @@ import {
|
|||||||
getDeliverySection,
|
getDeliverySection,
|
||||||
humanize,
|
humanize,
|
||||||
isFinalLockedVersion,
|
isFinalLockedVersion,
|
||||||
|
isHistoricalCampaignVersion,
|
||||||
isUserLockedVersion,
|
isUserLockedVersion,
|
||||||
isVersionReadyForDelivery,
|
isVersionReadyForDelivery,
|
||||||
} from "./utils/campaignView";
|
} from "./utils/campaignView";
|
||||||
@@ -165,8 +163,10 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
|
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
|
||||||
const deliveryQueued = currentWorkflowState === "queued";
|
const deliveryQueued = currentWorkflowState === "queued";
|
||||||
const deliveryStarted = ["sending", "sent", "completed", "partially_sent", "failed_partial"].includes(currentWorkflowState);
|
const deliveryStarted = ["sending", "sent", "completed", "partially_sent", "failed_partial"].includes(currentWorkflowState);
|
||||||
|
const historicalVersion = isHistoricalCampaignVersion(version, data.campaign?.current_version_id);
|
||||||
const finalVersion = isFinalLockedVersion(version);
|
const finalVersion = isFinalLockedVersion(version);
|
||||||
const userLockedVersion = isUserLockedVersion(version);
|
const userLockedVersion = isUserLockedVersion(version);
|
||||||
|
const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion;
|
||||||
|
|
||||||
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
||||||
const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview);
|
const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview);
|
||||||
@@ -186,50 +186,32 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
const mockMailbox = asRecord(mockResult?.mailbox);
|
const mockMailbox = asRecord(mockResult?.mailbox);
|
||||||
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
|
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
|
||||||
|
|
||||||
const validationState: FlowState = busy === "validate"
|
const validationReviewState: FlowState = busy === "validate"
|
||||||
? "running"
|
? "running"
|
||||||
: validationStale
|
: validationStale
|
||||||
? "stale"
|
? "stale"
|
||||||
: validationPresent && !validationOk
|
: validationPresent && !validationOk
|
||||||
? "danger"
|
? "danger"
|
||||||
: readyForDelivery && validationWarnings > 0
|
: readyForDelivery && (validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0)
|
||||||
? "warning"
|
? "warning"
|
||||||
: readyForDelivery
|
: readyForDelivery
|
||||||
? "complete"
|
? "complete"
|
||||||
: "active";
|
: "active";
|
||||||
|
|
||||||
const exceptionState: FlowState = !validationPresent
|
|
||||||
? "locked"
|
|
||||||
: validationErrors > 0
|
|
||||||
? "danger"
|
|
||||||
: validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0
|
|
||||||
? "warning"
|
|
||||||
: validationOk
|
|
||||||
? "complete"
|
|
||||||
: "active";
|
|
||||||
|
|
||||||
const buildState: FlowState = !readyForDelivery
|
|
||||||
? "locked"
|
|
||||||
: busy === "build"
|
|
||||||
? "running"
|
|
||||||
: hasBuild && buildBlocked > 0
|
|
||||||
? "danger"
|
|
||||||
: hasBuild && (buildNeedsReview > 0 || buildWarnings > 0)
|
|
||||||
? "warning"
|
|
||||||
: hasBuild
|
|
||||||
? "complete"
|
|
||||||
: "active";
|
|
||||||
|
|
||||||
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
||||||
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
|
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
|
||||||
|
|
||||||
const inspectState: FlowState = !hasBuild
|
const buildReviewState: FlowState = !readyForDelivery
|
||||||
? "locked"
|
? "locked"
|
||||||
: busy === "inspect"
|
: busy === "build" || busy === "inspect"
|
||||||
? "running"
|
? "running"
|
||||||
: inspectionSatisfied
|
: hasBuild && buildBlocked > 0
|
||||||
? "complete"
|
? "danger"
|
||||||
: "active";
|
: hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied)
|
||||||
|
? "warning"
|
||||||
|
: hasBuild && inspectionSatisfied
|
||||||
|
? "complete"
|
||||||
|
: "active";
|
||||||
|
|
||||||
const mockState: FlowState = !inspectionSatisfied
|
const mockState: FlowState = !inspectionSatisfied
|
||||||
? "locked"
|
? "locked"
|
||||||
@@ -268,61 +250,41 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
|
|
||||||
const stages: FlowStageDefinition[] = useMemo(() => [
|
const stages: FlowStageDefinition[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
id: "workflow-validate",
|
id: "workflow-validate-review",
|
||||||
title: "Validate campaign",
|
title: "Validate and inspect",
|
||||||
shortTitle: "Validate",
|
shortTitle: "Validate",
|
||||||
description: "Check campaign structure, recipients, templates and managed attachment matches.",
|
description: "Lock and validate the campaign, then inspect blocking errors, warnings and attachment exceptions in the same step.",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
state: validationState,
|
state: validationReviewState,
|
||||||
stateLabel: stateLabel(validationState),
|
stateLabel: stateLabel(validationReviewState),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "workflow-exceptions",
|
id: "workflow-build-review",
|
||||||
title: "Review exceptions",
|
title: "Build and review",
|
||||||
shortTitle: "Exceptions",
|
|
||||||
description: "Resolve blocking errors and make warnings visible before building messages.",
|
|
||||||
icon: AlertTriangle,
|
|
||||||
state: exceptionState,
|
|
||||||
stateLabel: stateLabel(exceptionState),
|
|
||||||
lockReason: "Run validation first to discover campaign exceptions.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "workflow-build",
|
|
||||||
title: "Build exact messages",
|
|
||||||
shortTitle: "Build",
|
shortTitle: "Build",
|
||||||
description: "Freeze the current recipients, rendered content and resolved attachment files.",
|
description: "Build the exact queue, resolve recipient values and managed files, then review only messages that need attention.",
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
state: buildState,
|
state: buildReviewState,
|
||||||
stateLabel: stateLabel(buildState),
|
stateLabel: stateLabel(buildReviewState),
|
||||||
lockReason: validationErrors > 0
|
lockReason: validationErrors > 0
|
||||||
? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.`
|
? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.`
|
||||||
: "Complete validation and lock the version first.",
|
: "Lock and validate the current working version first.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "workflow-inspect",
|
id: "workflow-mock-verify",
|
||||||
title: "Inspect built messages",
|
title: "Mock send and verify",
|
||||||
shortTitle: "Inspect",
|
|
||||||
description: "Review rendered content, recipients, validation state and the exact managed files attached to individual messages.",
|
|
||||||
icon: MailSearch,
|
|
||||||
state: inspectState,
|
|
||||||
stateLabel: stateLabel(inspectState),
|
|
||||||
lockReason: "Build the exact messages before inspecting them.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "workflow-mock",
|
|
||||||
title: "Run mock delivery",
|
|
||||||
shortTitle: "Mock send",
|
shortTitle: "Mock send",
|
||||||
description: "Exercise the delivery path, inspect recipient outcomes and open the captured MIME messages without contacting the real SMTP or IMAP server.",
|
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers.",
|
||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
state: mockState,
|
state: mockState,
|
||||||
stateLabel: stateLabel(mockState),
|
stateLabel: stateLabel(mockState),
|
||||||
lockReason: "Complete the message inspection step first.",
|
lockReason: "Build and complete the required message review first.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "workflow-send",
|
id: "workflow-send",
|
||||||
title: "Confirm and send",
|
title: "Confirm and send",
|
||||||
shortTitle: "Real send",
|
shortTitle: "Send",
|
||||||
description: "Review the final execution summary before opening the current real-send controls.",
|
description: "Review the final execution summary before opening the established real-send controls.",
|
||||||
icon: Send,
|
icon: Send,
|
||||||
state: sendState,
|
state: sendState,
|
||||||
stateLabel: stateLabel(sendState),
|
stateLabel: stateLabel(sendState),
|
||||||
@@ -332,17 +294,15 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
id: "workflow-results",
|
id: "workflow-results",
|
||||||
title: "Delivery results",
|
title: "Delivery results",
|
||||||
shortTitle: "Results",
|
shortTitle: "Results",
|
||||||
description: "Separate SMTP outcomes, IMAP append results and partial failures.",
|
description: "Review SMTP outcomes, IMAP append results, partial delivery and failures.",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
state: resultState,
|
state: resultState,
|
||||||
stateLabel: stateLabel(resultState),
|
stateLabel: stateLabel(resultState),
|
||||||
lockReason: "Delivery results become available after the real send starts.",
|
lockReason: "Delivery results become available after the real send starts.",
|
||||||
},
|
},
|
||||||
], [
|
], [
|
||||||
validationState,
|
validationReviewState,
|
||||||
exceptionState,
|
buildReviewState,
|
||||||
buildState,
|
|
||||||
inspectState,
|
|
||||||
mockState,
|
mockState,
|
||||||
sendState,
|
sendState,
|
||||||
resultState,
|
resultState,
|
||||||
@@ -350,7 +310,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
async function runValidation() {
|
async function runValidation() {
|
||||||
if (!version || busy || userLockedVersion || finalVersion || readyForDelivery) return;
|
if (!version || busy || readOnlyVersion || readyForDelivery) return;
|
||||||
setBusy("validate");
|
setBusy("validate");
|
||||||
setMessage("Validating the campaign, including managed attachment matches…");
|
setMessage("Validating the campaign, including managed attachment matches…");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -368,7 +328,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runBuild() {
|
async function runBuild() {
|
||||||
if (!version || busy || !readyForDelivery || deliveryQueued || deliveryStarted) return;
|
if (!version || busy || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted) return;
|
||||||
setBusy("build");
|
setBusy("build");
|
||||||
setMessage("Building exact messages and resolving managed attachment versions…");
|
setMessage("Building exact messages and resolving managed attachment versions…");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -411,7 +371,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runMockSend() {
|
async function runMockSend() {
|
||||||
if (!version || busy || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
||||||
setBusy("mock");
|
setBusy("mock");
|
||||||
setMessage("Running the complete mock-delivery flow…");
|
setMessage("Running the complete mock-delivery flow…");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -439,7 +399,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function completeInspection() {
|
async function completeInspection() {
|
||||||
if (!version || busy || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
||||||
setBusy("inspect");
|
setBusy("inspect");
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("Recording the completed message review…");
|
setMessage("Recording the completed message review…");
|
||||||
@@ -527,13 +487,14 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
This development layout uses the current campaign data and existing actions. The established Review & Send page remains available for comparison.
|
This development layout uses the current campaign data and existing actions. The established Review & Send page remains available for comparison.
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
|
|
||||||
{version && (readyForDelivery || userLockedVersion || finalVersion) && (
|
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) && (
|
||||||
<LockedVersionNotice
|
<LockedVersionNotice
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
version={version}
|
version={version}
|
||||||
|
currentVersionId={data.campaign?.current_version_id}
|
||||||
reload={reload}
|
reload={reload}
|
||||||
message={readyForDelivery ? "Locked and validated. Unlock validation to edit campaign data, or create an editable copy." : "Send snapshot. Copy to edit."}
|
message="This workflow is read-only for the selected version."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -544,16 +505,22 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
<WorkflowStage stage={stages[0]} nextState={stages[1].state}>
|
<WorkflowStage stage={stages[0]} nextState={stages[1].state}>
|
||||||
<div className="review-flow-fact-grid">
|
<div className="review-flow-fact-grid">
|
||||||
<WorkflowFact label="Status" value={validationPresent ? (validationOk ? "Passed" : "Needs attention") : "Not run"} />
|
<WorkflowFact label="Status" value={validationPresent ? (validationOk ? "Passed" : "Needs attention") : "Not run"} />
|
||||||
<WorkflowFact label="Errors" value={validationPresent ? validationErrors : "—"} />
|
<WorkflowFact label="Blocking" value={validationPresent ? validationErrors : "—"} />
|
||||||
<WorkflowFact label="Warnings" value={validationPresent ? validationWarnings : "—"} />
|
<WorkflowFact label="Warnings" value={validationPresent ? validationWarnings : "—"} />
|
||||||
<WorkflowFact label="Last change" value={formatDateTime(version?.updated_at)} />
|
<WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
|
||||||
</div>
|
</div>
|
||||||
{validationStale && <p className="review-flow-inline-note is-stale">The stored validation result is no longer an active delivery lock. Run validation again.</p>}
|
{validationStale && <p className="review-flow-inline-note is-stale">The stored validation result is no longer an active delivery lock. Run validation again.</p>}
|
||||||
<div className="button-row compact-actions">
|
{validationPresent && validationErrors === 0 && (
|
||||||
|
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> No blocking validation exceptions remain.</p>
|
||||||
|
)}
|
||||||
|
{validationErrors > 0 && (
|
||||||
|
<p className="review-flow-inline-note is-danger">Resolve the blocking entries, then validate again.</p>
|
||||||
|
)}
|
||||||
|
<div className="button-row compact-actions review-flow-stage-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void runValidation()}
|
onClick={() => void runValidation()}
|
||||||
disabled={!version || Boolean(busy) || userLockedVersion || finalVersion || readyForDelivery}
|
disabled={!version || Boolean(busy) || readOnlyVersion || readyForDelivery}
|
||||||
>
|
>
|
||||||
{busy === "validate"
|
{busy === "validate"
|
||||||
? "Validating…"
|
? "Validating…"
|
||||||
@@ -561,66 +528,41 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
? "Locked and validated"
|
? "Locked and validated"
|
||||||
: "Lock and validate"}
|
: "Lock and validate"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link className="btn btn-secondary" to="../send">Open issue table</Link>
|
||||||
|
<Link className="btn btn-secondary" to="../files">Review attachment rules</Link>
|
||||||
</div>
|
</div>
|
||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[1]} nextState={stages[2].state}>
|
<WorkflowStage stage={stages[1]} nextState={stages[2].state}>
|
||||||
<div className="review-flow-fact-grid">
|
|
||||||
<WorkflowFact label="Blocking" value={validationErrors} />
|
|
||||||
<WorkflowFact label="Warnings" value={validationWarnings} />
|
|
||||||
<WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
|
|
||||||
<WorkflowFact label="Delivery readiness" value={readyForDelivery ? "Ready" : "Not ready"} />
|
|
||||||
</div>
|
|
||||||
{validationPresent && validationErrors === 0 && (
|
|
||||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> No blocking validation exceptions remain.</p>
|
|
||||||
)}
|
|
||||||
{validationErrors > 0 && (
|
|
||||||
<p className="review-flow-inline-note is-danger">Resolve the blocking entries in the current issue table, then validate again.</p>
|
|
||||||
)}
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Link className="btn btn-secondary" to="../send">Open current issue table</Link>
|
|
||||||
<Link className="btn btn-secondary" to="../files">Review attachment rules</Link>
|
|
||||||
</div>
|
|
||||||
</WorkflowStage>
|
|
||||||
|
|
||||||
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
|
|
||||||
<div className="review-flow-fact-grid">
|
<div className="review-flow-fact-grid">
|
||||||
<WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} />
|
<WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} />
|
||||||
<WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} />
|
<WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} />
|
||||||
<WorkflowFact label="Needs review" value={hasBuild ? buildNeedsReview : "—"} />
|
<WorkflowFact label="Need review" value={hasBuild ? buildNeedsReview : "—"} />
|
||||||
<WorkflowFact label="Warnings" value={hasBuild ? buildWarnings : "—"} />
|
<WorkflowFact label="Reviewed" value={hasBuild ? reviewedRequiredCount : "—"} />
|
||||||
</div>
|
<WorkflowFact label="Loaded messages" value={builtReviewRows.length || "—"} />
|
||||||
<p className="muted">Building resolves recipient values and attachment patterns into the exact message queue used by the later stages.</p>
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || !readyForDelivery || deliveryQueued || deliveryStarted}>
|
|
||||||
{busy === "build" ? "Building…" : hasBuild ? "Build again" : "Build exact messages"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</WorkflowStage>
|
|
||||||
|
|
||||||
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
|
|
||||||
<div className="review-flow-fact-grid">
|
|
||||||
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
|
|
||||||
<WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
|
|
||||||
<WorkflowFact label="Need review" value={reviewRequiredRows.length} />
|
|
||||||
<WorkflowFact label="Reviewed" value={reviewedRequiredCount} />
|
|
||||||
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
|
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted">Building freezes the current recipients, rendered content and exact managed-file versions into the queue reviewed below.</p>
|
||||||
<div className="button-row compact-actions review-flow-stage-actions">
|
<div className="button-row compact-actions review-flow-stage-actions">
|
||||||
|
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted}>
|
||||||
|
{busy === "build" ? "Building…" : hasBuild ? "Build again" : "Build exact messages"}
|
||||||
|
</Button>
|
||||||
<Button onClick={() => void loadBuiltMessages()} disabled={!hasBuild || Boolean(busy)}>
|
<Button onClick={() => void loadBuiltMessages()} disabled={!hasBuild || Boolean(busy)}>
|
||||||
{busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload message review" : "Load message review"}
|
{busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload review" : "Load review"}
|
||||||
</Button>
|
</Button>
|
||||||
<Link className="btn btn-secondary" to="../template">Open template editor</Link>
|
<Link className="btn btn-secondary" to="../template">Open template editor</Link>
|
||||||
<Button
|
{reviewRequiredRows.length > 0 && (
|
||||||
variant="primary"
|
<Button
|
||||||
onClick={() => void completeInspection()}
|
variant="primary"
|
||||||
disabled={automaticInspectionComplete || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
|
onClick={() => void completeInspection()}
|
||||||
>
|
disabled={readOnlyVersion || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
|
||||||
{automaticInspectionComplete || messageReviewComplete ? "Inspection completed" : "Complete inspection"}
|
>
|
||||||
</Button>
|
{messageReviewComplete ? "Review completed" : "Complete review"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{automaticInspectionComplete && (
|
{automaticInspectionComplete && (
|
||||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> All built messages are ready. No manual inspection acknowledgement is required.</p>
|
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> All built messages are ready. No manual review acknowledgement is required.</p>
|
||||||
)}
|
)}
|
||||||
{builtReviewRows.length > 0 && (
|
{builtReviewRows.length > 0 && (
|
||||||
<div className="review-flow-data-section">
|
<div className="review-flow-data-section">
|
||||||
@@ -630,15 +572,16 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
|
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
|
||||||
getRowKey={builtMessageKey}
|
getRowKey={builtMessageKey}
|
||||||
initialFilters={{ validation: MESSAGE_REVIEW_DEFAULT_FILTER }}
|
initialFilters={{ validation: MESSAGE_REVIEW_DEFAULT_FILTER }}
|
||||||
emptyText="No messages require review. Ready messages are hidden by the default validation filter."
|
emptyText="No built messages are available."
|
||||||
|
filteredEmptyText="No messages match the active filters."
|
||||||
className="data-table-wrap data-table compact-table"
|
className="data-table-wrap data-table compact-table"
|
||||||
/>
|
/>
|
||||||
<p className="muted small-note">Ready messages are hidden initially. Use the Validation filter to include them. When review is required, open every listed issue message before completing the step.</p>
|
<p className="muted small-note">Ready messages are hidden initially. Use the Validation filter to include them. When review is required, open every issue message before completing the step.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[4]} nextState={stages[5].state}>
|
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
|
||||||
<div className="review-flow-fact-grid">
|
<div className="review-flow-fact-grid">
|
||||||
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
|
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
|
||||||
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
|
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
|
||||||
@@ -646,7 +589,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
|
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions review-flow-stage-actions">
|
<div className="button-row compact-actions review-flow-stage-actions">
|
||||||
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
||||||
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
|
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
|
||||||
</Button>
|
</Button>
|
||||||
<Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link>
|
<Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link>
|
||||||
@@ -679,7 +622,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
)}
|
)}
|
||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[5]} nextState={stages[6].state}>
|
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
|
||||||
<div className="review-flow-execution-summary">
|
<div className="review-flow-execution-summary">
|
||||||
<div><span>Recipients</span><strong>{jobsTotal || "—"}</strong></div>
|
<div><span>Recipients</span><strong>{jobsTotal || "—"}</strong></div>
|
||||||
<div><span>Messages to send</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
|
<div><span>Messages to send</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
|
||||||
@@ -690,13 +633,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
<div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div>
|
<div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div>
|
||||||
<div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
<div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="review-flow-inline-note is-warning">The development page intentionally delegates the real-send action to the established page while this layout is evaluated.</p>
|
<p className="review-flow-inline-note is-warning">The workflow preview delegates the real-send action to the established page while this layout is evaluated.</p>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Link className="btn btn-primary" to="../send">Open final send controls</Link>
|
<Link className="btn btn-primary" to="../send">Open final send controls</Link>
|
||||||
</div>
|
</div>
|
||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[6]}>
|
<WorkflowStage stage={stages[4]}>
|
||||||
<div className="review-flow-fact-grid">
|
<div className="review-flow-fact-grid">
|
||||||
<WorkflowFact label="SMTP accepted" value={sentCount} />
|
<WorkflowFact label="SMTP accepted" value={sentCount} />
|
||||||
<WorkflowFact label="SMTP failed" value={failedCount} />
|
<WorkflowFact label="SMTP failed" value={failedCount} />
|
||||||
@@ -807,14 +750,11 @@ function WorkflowStage({
|
|||||||
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
|
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
|
||||||
{nextState && <div className="review-flow-stage-line" />}
|
{nextState && <div className="review-flow-stage-line" />}
|
||||||
</div>
|
</div>
|
||||||
<article className={`card review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
|
<article className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
|
||||||
<header className="card-header review-flow-stage-header">
|
<header className="card-header review-flow-stage-header">
|
||||||
<h2>
|
<h2>
|
||||||
<span>{stage.title}</span>
|
<span>{stage.title}</span>
|
||||||
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />}
|
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />}
|
||||||
<InlineHelp>{stage.description}</InlineHelp>
|
|
||||||
</h2>
|
|
||||||
<div className="review-flow-stage-header-actions">
|
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<span
|
<span
|
||||||
className="review-flow-state-badge"
|
className="review-flow-state-badge"
|
||||||
@@ -825,15 +765,18 @@ function WorkflowStage({
|
|||||||
{stage.stateLabel}
|
{stage.stateLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<InlineHelp>{stage.description}</InlineHelp>
|
||||||
|
</h2>
|
||||||
|
<div className="review-flow-stage-header-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="review-flow-collapse-button"
|
className="card-collapse-toggle"
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`}
|
aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`}
|
||||||
title={collapsed ? "Expand card" : "Collapse card"}
|
title={collapsed ? "Show content" : "Show header only"}
|
||||||
onClick={() => setCollapsed((value) => !value)}
|
onClick={() => setCollapsed((value) => !value)}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight size={18} aria-hidden="true" /> : <ChevronDown size={18} aria-hidden="true" />}
|
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVe
|
|||||||
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
const delivery = getDeliverySection(version);
|
const delivery = getDeliverySection(version);
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
@@ -151,7 +151,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</DismissibleAlert>}
|
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</DismissibleAlert>}
|
||||||
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage} floating>{mockMessage}</DismissibleAlert>}
|
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage} floating>{mockMessage}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
|
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
|
||||||
<div className="metric-grid">
|
<div className="metric-grid">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
|
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -184,7 +184,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
||||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
import ToggleSwitch from "../../../components/ToggleSwitch";
|
||||||
import { getBool, getText } from "../utils/draftEditor";
|
import { getBool, getText } from "../utils/draftEditor";
|
||||||
import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
|
import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
|
||||||
@@ -85,7 +85,7 @@ export default function AttachmentRulesOverlay({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<footer className="modal-footer">
|
||||||
<Button variant="primary" onClick={addOverlayRule} disabled={disabled}>Add file</Button>
|
<Button variant="primary" onClick={addOverlayRule} disabled={disabled || basePaths.length === 0}>Add file</Button>
|
||||||
<Button onClick={closeOverlay}>Close</Button>
|
<Button onClick={closeOverlay}>Close</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@ export function AttachmentRulesTable({
|
|||||||
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
||||||
{showAddButton && (
|
{showAddButton && (
|
||||||
<div className="button-row compact-actions attachment-rules-footer-actions">
|
<div className="button-row compact-actions attachment-rules-footer-actions">
|
||||||
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled}>Add file</Button>
|
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +148,8 @@ export function AttachmentRulesDataGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addRule(afterIndex = rules.length - 1) {
|
function addRule(afterIndex = rules.length - 1) {
|
||||||
const nextRule = createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? "");
|
if (disabled || basePaths.length === 0) return;
|
||||||
|
const nextRule = createAttachmentRule(basePaths[0].path, nextAttachmentLabel(rules), basePaths[0].id);
|
||||||
onChange(insertAfter(rules, afterIndex, nextRule));
|
onChange(insertAfter(rules, afterIndex, nextRule));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +170,14 @@ export function AttachmentRulesDataGrid({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rule = rules[ruleIndex] ?? {};
|
const rule = rules[ruleIndex] ?? {};
|
||||||
const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
|
const currentPath = getText(rule, "base_dir");
|
||||||
const currentBasePathId = getText(rule, "base_path_id");
|
const currentBasePathId = getText(rule, "base_path_id");
|
||||||
const basePath = basePaths.find((item) => item.id === currentBasePathId) ?? basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null;
|
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
|
||||||
|
const basePath = basePaths.find((item) => item.id === currentBasePathId)
|
||||||
|
?? basePaths.find((item) => item.path === currentPath)
|
||||||
|
?? (!explicitlyReferenced ? basePaths[0] : undefined)
|
||||||
|
?? null;
|
||||||
|
if (!basePath) return;
|
||||||
setFileChooser({ ruleIndex, basePath });
|
setFileChooser({ ruleIndex, basePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +202,8 @@ export function AttachmentRulesDataGrid({
|
|||||||
rows={rules}
|
rows={rules}
|
||||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
columns={attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||||
getRowKey={(rule, index) => String(rule.id ?? index)}
|
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||||
emptyText={<div className="data-grid-empty-action"><span>{emptyText}</span><Button variant="primary" onClick={() => addRule(-1)} disabled={disabled}>Add file</Button></div>}
|
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
|
||||||
|
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
|
||||||
className="attachment-rules-table-wrap attachment-rules-table"
|
className="attachment-rules-table-wrap attachment-rules-table"
|
||||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -240,22 +247,26 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (rule, index) => {
|
render: (rule, index) => {
|
||||||
const currentBasePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "");
|
const currentBasePathValue = getText(rule, "base_dir");
|
||||||
const currentBasePathId = getText(rule, "base_path_id");
|
const currentBasePathId = getText(rule, "base_path_id");
|
||||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ?? basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? basePaths[0];
|
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
||||||
return basePaths.length > 0 ? (
|
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
|
||||||
|
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
|
||||||
|
?? (!explicitlyReferenced ? basePaths[0] : undefined);
|
||||||
|
return (
|
||||||
<select
|
<select
|
||||||
value={selectedBasePath?.id ?? ""}
|
value={selectedBasePath?.id ?? ""}
|
||||||
disabled={disabled}
|
disabled={disabled || basePaths.length === 0}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
||||||
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{!selectedBasePath && (
|
||||||
|
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
|
||||||
|
)}
|
||||||
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
|
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
|
||||||
<input value={currentBasePathValue} disabled={disabled} readOnly placeholder="optional/folder" />
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
||||||
@@ -272,7 +283,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
|||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={getText(rule, "file_filter")}
|
value={getText(rule, "file_filter")}
|
||||||
disabled={disabled}
|
disabled={disabled || basePaths.length === 0}
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
placeholder="Choose a managed file or pattern"
|
placeholder="Choose a managed file or pattern"
|
||||||
@@ -284,7 +295,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
|
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
value: (rule) => getText(rule, "file_filter")
|
value: (rule) => getText(rule, "file_filter")
|
||||||
@@ -293,7 +304,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
width: 150,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_rule, index) => (
|
render: (_rule, index) => (
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
canUnlockValidationVersion,
|
canUnlockValidationVersion,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
isFinalLockedVersion,
|
isFinalLockedVersion,
|
||||||
|
isHistoricalCampaignVersion,
|
||||||
isPermanentUserLockedVersion,
|
isPermanentUserLockedVersion,
|
||||||
isTemporaryUserLockedVersion,
|
isTemporaryUserLockedVersion,
|
||||||
} from "../utils/campaignView";
|
} from "../utils/campaignView";
|
||||||
@@ -22,23 +23,27 @@ type LockedVersionNoticeProps = {
|
|||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
version: CampaignVersionDetail | CampaignVersionListItem | null;
|
version: CampaignVersionDetail | CampaignVersionListItem | null;
|
||||||
|
currentVersionId?: string | null;
|
||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
|
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
|
||||||
|
|
||||||
export default function LockedVersionNotice({ settings, campaignId, version, reload, message }: LockedVersionNoticeProps) {
|
export default function LockedVersionNotice({ settings, campaignId, version, currentVersionId, reload, message }: LockedVersionNoticeProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [localError, setLocalError] = useState("");
|
const [localError, setLocalError] = useState("");
|
||||||
const [localMessage, setLocalMessage] = useState("");
|
const [localMessage, setLocalMessage] = useState("");
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
|
||||||
|
|
||||||
const validationLock = canUnlockValidationVersion(version);
|
const historicalVersion = isHistoricalCampaignVersion(version, currentVersionId);
|
||||||
const temporaryUserLock = isTemporaryUserLockedVersion(version);
|
const validationLock = !historicalVersion && canUnlockValidationVersion(version);
|
||||||
|
const temporaryUserLock = !historicalVersion && isTemporaryUserLockedVersion(version);
|
||||||
const permanentUserLock = isPermanentUserLockedVersion(version);
|
const permanentUserLock = isPermanentUserLockedVersion(version);
|
||||||
const finalLock = isFinalLockedVersion(version);
|
const finalLock = isFinalLockedVersion(version);
|
||||||
|
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
|
||||||
const presentation = lockPresentation(version, {
|
const presentation = lockPresentation(version, {
|
||||||
|
historicalVersion,
|
||||||
validationLock,
|
validationLock,
|
||||||
temporaryUserLock,
|
temporaryUserLock,
|
||||||
permanentUserLock,
|
permanentUserLock,
|
||||||
@@ -114,9 +119,11 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
{canCreateEditableCopy && (
|
||||||
{busy ? "Creating copy…" : "Create editable copy"}
|
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||||
</Button>
|
{busy ? "Creating copy…" : "Create editable copy"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -138,6 +145,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LockFlags = {
|
type LockFlags = {
|
||||||
|
historicalVersion: boolean;
|
||||||
validationLock: boolean;
|
validationLock: boolean;
|
||||||
temporaryUserLock: boolean;
|
temporaryUserLock: boolean;
|
||||||
permanentUserLock: boolean;
|
permanentUserLock: boolean;
|
||||||
@@ -148,6 +156,14 @@ function lockPresentation(
|
|||||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||||
flags: LockFlags,
|
flags: LockFlags,
|
||||||
): { kind: string; title: string; description: string; info: string } {
|
): { kind: string; title: string; description: string; info: string } {
|
||||||
|
if (flags.historicalVersion) {
|
||||||
|
return {
|
||||||
|
kind: "historical",
|
||||||
|
title: "Historical version.",
|
||||||
|
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
|
||||||
|
info: `Version #${version?.version_number ?? "—"}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (flags.temporaryUserLock) {
|
if (flags.temporaryUserLock) {
|
||||||
return {
|
return {
|
||||||
kind: "temporary-user",
|
kind: "temporary-user",
|
||||||
|
|||||||
@@ -99,8 +99,31 @@ export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): Attachme
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
||||||
const enabled = paths.filter((basePath) => basePath.allow_individual);
|
return paths.filter((basePath) => basePath.allow_individual);
|
||||||
return enabled.length > 0 ? enabled : paths;
|
}
|
||||||
|
|
||||||
|
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
|
||||||
|
const basePathId = getText(rule, "base_path_id");
|
||||||
|
if (basePathId) return basePathId === basePath.id;
|
||||||
|
return getText(rule, "base_dir") === basePath.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
|
||||||
|
const entries = asRecord(entriesValue);
|
||||||
|
return asArray(entries.inline)
|
||||||
|
.map(asRecord)
|
||||||
|
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
|
||||||
|
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
|
||||||
|
const entries = asRecord(entriesValue);
|
||||||
|
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
|
||||||
|
}));
|
||||||
|
return { ...entries, inline };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,14 +103,31 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
|
|||||||
return ["approved", "built"].includes(version.workflow_state ?? "");
|
return ["approved", "built"].includes(version.workflow_state ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
export function isHistoricalCampaignVersion(
|
||||||
|
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||||
|
currentVersionId?: string | null,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(version && currentVersionId && version.id !== currentVersionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuditLockedVersion(
|
||||||
|
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||||
|
currentVersionId?: string | null,
|
||||||
|
): boolean {
|
||||||
if (!version) return false;
|
if (!version) return false;
|
||||||
|
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
|
||||||
if (version.locked_at || isUserLockedVersion(version)) return true;
|
if (version.locked_at || isUserLockedVersion(version)) return true;
|
||||||
return isFinalLockedVersion(version);
|
return isFinalLockedVersion(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
export function versionLockReason(
|
||||||
|
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||||
|
currentVersionId?: string | null,
|
||||||
|
): string {
|
||||||
if (!version) return "No campaign version is loaded.";
|
if (!version) return "No campaign version is loaded.";
|
||||||
|
if (isHistoricalCampaignVersion(version, currentVersionId)) {
|
||||||
|
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
|
||||||
|
}
|
||||||
if (isTemporaryUserLockedVersion(version)) {
|
if (isTemporaryUserLockedVersion(version)) {
|
||||||
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
|||||||
const index = steps.findIndex((s) => s.id === activeStep);
|
const index = steps.findIndex((s) => s.id === activeStep);
|
||||||
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -93,8 +93,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
version={data.currentVersion}
|
version={data.currentVersion}
|
||||||
|
currentVersionId={data.campaign?.current_version_id}
|
||||||
reload={reload}
|
reload={reload}
|
||||||
message="Create an editable copy before continuing the creation wizard."
|
message="This wizard is read-only for the selected version."
|
||||||
/>
|
/>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<Link to="../.."><Button>Back to overview</Button></Link>
|
<Link to="../.."><Button>Back to overview</Button></Link>
|
||||||
|
|||||||
@@ -1785,7 +1785,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
margin-left: auto;
|
margin-left: 2px;
|
||||||
padding: 4px 9px;
|
padding: 4px 9px;
|
||||||
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
|
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
@@ -1810,7 +1810,11 @@
|
|||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-flow-stage-card.is-locked .review-flow-stage-header,
|
.review-flow-stage-card.is-locked .review-flow-stage-header {
|
||||||
|
opacity: .58;
|
||||||
|
filter: grayscale(.25);
|
||||||
|
}
|
||||||
|
|
||||||
.review-flow-stage-card.is-locked .review-flow-stage-content {
|
.review-flow-stage-card.is-locked .review-flow-stage-content {
|
||||||
opacity: .38;
|
opacity: .38;
|
||||||
filter: grayscale(.35);
|
filter: grayscale(.35);
|
||||||
@@ -2023,28 +2027,6 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-flow-collapse-button {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: inline-grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--muted);
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-flow-collapse-button:hover,
|
|
||||||
.review-flow-collapse-button:focus-visible {
|
|
||||||
border-color: var(--line-dark);
|
|
||||||
color: var(--text-strong);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-flow-stage-card.is-collapsed .review-flow-stage-header {
|
.review-flow-stage-card.is-collapsed .review-flow-stage-header {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
|
|||||||
@@ -1185,64 +1185,36 @@
|
|||||||
/* Consistent row-level collection actions for editable DataGrid tables. */
|
/* Consistent row-level collection actions for editable DataGrid tables. */
|
||||||
.data-grid-row-actions {
|
.data-grid-row-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 28px);
|
grid-template-columns: repeat(4, 36px);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action {
|
.data-grid-row-action.btn {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 28px;
|
width: 36px;
|
||||||
height: 28px;
|
height: 36px;
|
||||||
border: 1px solid var(--line);
|
min-width: 36px;
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action:hover:not(:disabled),
|
.data-grid-row-action.btn:disabled {
|
||||||
.data-grid-row-action:focus-visible:not(:disabled) {
|
opacity: .45;
|
||||||
border-color: var(--line-dark);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
outline: none;
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action.is-add {
|
.data-grid-empty-message {
|
||||||
border-color: color-mix(in srgb, var(--blue) 52%, var(--line));
|
color: var(--muted);
|
||||||
background: color-mix(in srgb, var(--blue) 10%, #fff);
|
min-height: 64px;
|
||||||
color: var(--blue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action.is-remove {
|
.data-grid-empty-action-cell {
|
||||||
border-color: rgba(171, 70, 61, .35);
|
justify-content: flex-end;
|
||||||
background: rgba(171, 70, 61, .06);
|
min-height: 64px;
|
||||||
color: #9f433b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action:disabled {
|
.data-grid-empty-row-actions {
|
||||||
cursor: default;
|
justify-content: end;
|
||||||
opacity: .42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-grid-row-action.is-placeholder {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-grid-empty-action {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-grid-empty-action > span {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user