DataGrid list type; Review/Send refinment; User lock; Table actions
This commit is contained in:
@@ -48,6 +48,9 @@ export type CampaignVersionListItem = {
|
|||||||
published_at?: string | null;
|
published_at?: string | null;
|
||||||
locked_at?: string | null;
|
locked_at?: string | null;
|
||||||
locked_by_user_id?: string | null;
|
locked_by_user_id?: string | null;
|
||||||
|
user_lock_state?: "temporary" | "permanent" | null;
|
||||||
|
user_locked_at?: string | null;
|
||||||
|
user_locked_by_user_id?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
validation_summary?: Record<string, unknown> | null;
|
validation_summary?: Record<string, unknown> | null;
|
||||||
@@ -186,6 +189,11 @@ export type CampaignMockSendPayload = {
|
|||||||
check_files?: boolean;
|
check_files?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignReviewStatePayload = {
|
||||||
|
inspection_complete: boolean;
|
||||||
|
reviewed_message_keys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
||||||
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
||||||
@@ -261,6 +269,36 @@ export async function unlockCampaignVersionValidation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function lockCampaignVersionTemporarily(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId: string
|
||||||
|
): Promise<CampaignVersionDetail> {
|
||||||
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockCampaignVersionUserLock(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId: string
|
||||||
|
): Promise<CampaignVersionDetail> {
|
||||||
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lockCampaignVersionPermanently(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId: string
|
||||||
|
): Promise<CampaignVersionDetail> {
|
||||||
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCampaignVersion(
|
export async function updateCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
@@ -372,8 +410,25 @@ export async function getCampaignSummary(settings: ApiSettings, campaignId: stri
|
|||||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`);
|
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignJobs(settings: ApiSettings, campaignId: string): Promise<{ jobs: Record<string, unknown>[] }> {
|
export async function getCampaignJobs(
|
||||||
return apiFetch<{ jobs: Record<string, unknown>[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs`);
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId?: string
|
||||||
|
): Promise<{ jobs: Record<string, unknown>[] }> {
|
||||||
|
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||||
|
return apiFetch<{ jobs: Record<string, unknown>[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCampaignReviewState(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId: string,
|
||||||
|
payload: CampaignReviewStatePayload
|
||||||
|
): Promise<CampaignVersionDetail> {
|
||||||
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queueCampaign(
|
export async function queueCampaign(
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default function StatusBadge({ status }: { status: string }) {
|
export default function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{status}</span>;
|
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{label ?? status}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import StatusBadge from "../StatusBadge";
|
||||||
|
|
||||||
export type DataGridSortDirection = "asc" | "desc";
|
export type DataGridSortDirection = "asc" | "desc";
|
||||||
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date";
|
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
|
||||||
|
|
||||||
|
export type DataGridListOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridListConfig<T> = {
|
||||||
|
options: DataGridListOption[];
|
||||||
|
display?: "text" | "pill";
|
||||||
|
editable?: boolean;
|
||||||
|
onChange?: (row: T, index: number, value: string) => void;
|
||||||
|
optionsEditable?: boolean;
|
||||||
|
onOptionsChange?: (options: DataGridListOption[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
||||||
|
|
||||||
@@ -17,6 +33,8 @@ export type DataGridColumn<T> = {
|
|||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
filterType?: DataGridFilterType;
|
filterType?: DataGridFilterType;
|
||||||
|
columnType?: "default" | "from-list";
|
||||||
|
list?: DataGridListConfig<T>;
|
||||||
sticky?: "start" | "end";
|
sticky?: "start" | "end";
|
||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -44,6 +62,20 @@ type DataGridProps<T> = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
rowClassName?: (row: T, index: number) => string | undefined;
|
rowClassName?: (row: T, index: number) => string | undefined;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
|
initialFilters?: Record<string, string | string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridRowActionsProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
removeDisabled?: boolean;
|
||||||
|
onAddBelow: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMoveUp?: () => void;
|
||||||
|
onMoveDown?: () => void;
|
||||||
|
addLabel?: string;
|
||||||
|
removeLabel?: string;
|
||||||
|
moveUpLabel?: string;
|
||||||
|
moveDownLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterPosition = {
|
type FilterPosition = {
|
||||||
@@ -65,10 +97,13 @@ export default function DataGrid<T>({
|
|||||||
fit = "container",
|
fit = "container",
|
||||||
className = "",
|
className = "",
|
||||||
rowClassName,
|
rowClassName,
|
||||||
storageKey
|
storageKey,
|
||||||
|
initialFilters = {}
|
||||||
}: DataGridProps<T>) {
|
}: DataGridProps<T>) {
|
||||||
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
|
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
|
||||||
const [state, setState] = useState<DataGridState>(() => loadState(localStorageKey));
|
const initialFiltersKey = JSON.stringify(initialFilters);
|
||||||
|
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
||||||
|
const [state, setState] = useState<DataGridState>(() => mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters));
|
||||||
const [resizeState, setResizeState] = useState<{
|
const [resizeState, setResizeState] = useState<{
|
||||||
columnId: string;
|
columnId: string;
|
||||||
startX: number;
|
startX: number;
|
||||||
@@ -93,6 +128,10 @@ export default function DataGrid<T>({
|
|||||||
setState((current) => sanitizePersistedColumnState(columns, current));
|
setState((current) => sanitizePersistedColumnState(columns, current));
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState((current) => mergeInitialFilters(current, normalizedInitialFilters));
|
||||||
|
}, [normalizedInitialFilters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
||||||
@@ -247,7 +286,9 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
const filterTypes = useMemo(() => {
|
const filterTypes = useMemo(() => {
|
||||||
const result: Record<string, DataGridFilterType> = {};
|
const result: Record<string, DataGridFilterType> = {};
|
||||||
for (const column of columns) result[column.id] = column.filterType ?? inferFilterType(column, rows);
|
for (const column of columns) {
|
||||||
|
result[column.id] = column.columnType === "from-list" ? "list" : column.filterType ?? inferFilterType(column, rows);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [columns, rows]);
|
}, [columns, rows]);
|
||||||
|
|
||||||
@@ -301,10 +342,13 @@ export default function DataGrid<T>({
|
|||||||
setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } }));
|
setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilter(columnId: string) {
|
function clearFilter(columnId: string, defaults: Record<string, string>) {
|
||||||
setState((current) => {
|
setState((current) => {
|
||||||
const nextFilters = { ...(current.filters ?? {}) };
|
const nextFilters = { ...(current.filters ?? {}) };
|
||||||
delete nextFilters[columnId];
|
// Keep an explicit empty value when the column has an initial filter so
|
||||||
|
// clearing it remains a user choice after remounting the grid.
|
||||||
|
if (Object.prototype.hasOwnProperty.call(defaults, columnId)) nextFilters[columnId] = "";
|
||||||
|
else delete nextFilters[columnId];
|
||||||
return { ...current, filters: nextFilters };
|
return { ...current, filters: nextFilters };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -422,7 +466,7 @@ export default function DataGrid<T>({
|
|||||||
className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
|
className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
|
||||||
style={stickyStyle(column, stickyOffsets[columnIndex])}
|
style={stickyStyle(column, stickyOffsets[columnIndex])}
|
||||||
>
|
>
|
||||||
{column.render ? column.render(row, originalIndex) : stringifyCell(column.value?.(row, originalIndex))}
|
{renderCell(column, row, originalIndex)}
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
})}
|
})}
|
||||||
@@ -433,9 +477,13 @@ export default function DataGrid<T>({
|
|||||||
column={activeFilterColumn}
|
column={activeFilterColumn}
|
||||||
filterType={filterTypes[activeFilterColumn.id]}
|
filterType={filterTypes[activeFilterColumn.id]}
|
||||||
value={state.filters?.[activeFilterColumn.id] ?? ""}
|
value={state.filters?.[activeFilterColumn.id] ?? ""}
|
||||||
|
listOptions={listOptionsForColumn(activeFilterColumn, rows)}
|
||||||
|
listDisplay={activeFilterColumn.list?.display ?? "text"}
|
||||||
|
optionsEditable={Boolean(activeFilterColumn.list?.optionsEditable && activeFilterColumn.list?.onOptionsChange)}
|
||||||
position={filterPosition}
|
position={filterPosition}
|
||||||
onChange={(value) => patchFilter(activeFilterColumn.id, value)}
|
onChange={(value) => patchFilter(activeFilterColumn.id, value)}
|
||||||
onClear={() => clearFilter(activeFilterColumn.id)}
|
onClear={() => clearFilter(activeFilterColumn.id, normalizedInitialFilters)}
|
||||||
|
onOptionsChange={activeFilterColumn.list?.onOptionsChange}
|
||||||
onClose={() => setOpenFilterColumnId(null)}
|
onClose={() => setOpenFilterColumnId(null)}
|
||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
@@ -444,18 +492,84 @@ export default function DataGrid<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DataGridRowActions({
|
||||||
|
disabled = false,
|
||||||
|
removeDisabled = false,
|
||||||
|
onAddBelow,
|
||||||
|
onRemove,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
addLabel = "Add row below",
|
||||||
|
removeLabel = "Remove row",
|
||||||
|
moveUpLabel = "Move row up",
|
||||||
|
moveDownLabel = "Move row down"
|
||||||
|
}: DataGridRowActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="data-grid-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="data-grid-row-action is-add"
|
||||||
|
aria-label={addLabel}
|
||||||
|
title={addLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onAddBelow}
|
||||||
|
>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`data-grid-row-action is-reorder${onMoveUp ? "" : " is-placeholder"}`}
|
||||||
|
aria-label={moveUpLabel}
|
||||||
|
title={moveUpLabel}
|
||||||
|
aria-hidden={!onMoveUp}
|
||||||
|
tabIndex={onMoveUp ? 0 : -1}
|
||||||
|
disabled={disabled || !onMoveUp}
|
||||||
|
onClick={onMoveUp}
|
||||||
|
>
|
||||||
|
<ArrowUp size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`data-grid-row-action is-reorder${onMoveDown ? "" : " is-placeholder"}`}
|
||||||
|
aria-label={moveDownLabel}
|
||||||
|
title={moveDownLabel}
|
||||||
|
aria-hidden={!onMoveDown}
|
||||||
|
tabIndex={onMoveDown ? 0 : -1}
|
||||||
|
disabled={disabled || !onMoveDown}
|
||||||
|
onClick={onMoveDown}
|
||||||
|
>
|
||||||
|
<ArrowDown size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="data-grid-row-action is-remove"
|
||||||
|
aria-label={removeLabel}
|
||||||
|
title={removeLabel}
|
||||||
|
disabled={disabled || removeDisabled}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type FilterPopoverProps = {
|
type FilterPopoverProps = {
|
||||||
column: Pick<DataGridColumn<unknown>, "id" | "header">;
|
column: Pick<DataGridColumn<unknown>, "id" | "header">;
|
||||||
filterType: DataGridFilterType;
|
filterType: DataGridFilterType;
|
||||||
value: string;
|
value: string;
|
||||||
|
listOptions: DataGridListOption[];
|
||||||
|
listDisplay: "text" | "pill";
|
||||||
|
optionsEditable: boolean;
|
||||||
position: FilterPosition;
|
position: FilterPosition;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
onOptionsChange?: (options: DataGridListOption[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function FilterPopover(
|
const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function FilterPopover(
|
||||||
{ column, filterType, value, position, onChange, onClear, onClose },
|
{ column, filterType, value, listOptions, listDisplay, optionsEditable, position, onChange, onClear, onOptionsChange, onClose },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const parsed = parseTypedFilter(value, filterType);
|
const parsed = parseTypedFilter(value, filterType);
|
||||||
@@ -472,15 +586,23 @@ const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function Fi
|
|||||||
<strong>Filter {column.header}</strong>
|
<strong>Filter {column.header}</strong>
|
||||||
<button type="button" aria-label="Close filter" onClick={onClose}><X size={15} /></button>
|
<button type="button" aria-label="Close filter" onClick={onClose}><X size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
{filterType === "boolean" ? (
|
{filterType === "list" ? (
|
||||||
<label className="data-grid-filter-field">
|
<ListFilterEditor
|
||||||
<span>Value</span>
|
options={listOptions}
|
||||||
<select value={value} onChange={(event) => onChange(event.target.value)} autoFocus>
|
display={listDisplay}
|
||||||
<option value="">All</option>
|
value={value}
|
||||||
<option value="true">Yes / active</option>
|
editable={optionsEditable}
|
||||||
<option value="false">No / inactive</option>
|
onChange={onChange}
|
||||||
</select>
|
onOptionsChange={onOptionsChange}
|
||||||
</label>
|
/>
|
||||||
|
) : filterType === "boolean" ? (
|
||||||
|
<ListFilterEditor
|
||||||
|
options={BOOLEAN_FILTER_OPTIONS}
|
||||||
|
display="text"
|
||||||
|
value={value}
|
||||||
|
editable={false}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
) : filterType === "number" || filterType === "integer" || filterType === "date" ? (
|
) : filterType === "number" || filterType === "integer" || filterType === "date" ? (
|
||||||
<div className="data-grid-filter-stack">
|
<div className="data-grid-filter-stack">
|
||||||
<label className="data-grid-filter-field">
|
<label className="data-grid-filter-field">
|
||||||
@@ -519,12 +641,132 @@ const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function Fi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function ListFilterEditor({
|
||||||
|
options,
|
||||||
|
display,
|
||||||
|
value,
|
||||||
|
editable,
|
||||||
|
onChange,
|
||||||
|
onOptionsChange,
|
||||||
|
}: {
|
||||||
|
options: DataGridListOption[];
|
||||||
|
display: "text" | "pill";
|
||||||
|
value: string;
|
||||||
|
editable: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onOptionsChange?: (options: DataGridListOption[]) => void;
|
||||||
|
}) {
|
||||||
|
const [newValue, setNewValue] = useState("");
|
||||||
|
const selected = parseListFilter(value, options.map((option) => option.value));
|
||||||
|
const selectedSet = new Set(selected);
|
||||||
|
|
||||||
|
function toggleOption(optionValue: string) {
|
||||||
|
const next = new Set(selectedSet);
|
||||||
|
if (next.has(optionValue)) next.delete(optionValue);
|
||||||
|
else next.add(optionValue);
|
||||||
|
onChange(formatListSelection([...next], options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption() {
|
||||||
|
const normalized = newValue.trim();
|
||||||
|
if (!normalized || !onOptionsChange || options.some((option) => option.value === normalized)) return;
|
||||||
|
onOptionsChange([...options, { value: normalized, label: humanizeListValue(normalized) }]);
|
||||||
|
setNewValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(optionValue: string) {
|
||||||
|
if (!onOptionsChange) return;
|
||||||
|
onOptionsChange(options.filter((option) => option.value !== optionValue));
|
||||||
|
if (selectedSet.has(optionValue)) {
|
||||||
|
const nextOptions = options.filter((option) => option.value !== optionValue);
|
||||||
|
onChange(formatListSelection(selected.filter((candidate) => candidate !== optionValue), nextOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="data-grid-list-filter">
|
||||||
|
<div className="data-grid-list-filter-actions">
|
||||||
|
<button type="button" onClick={() => onChange("")}>Select all</button>
|
||||||
|
<button type="button" onClick={() => onChange(formatListFilter([]))}>Deselect all</button>
|
||||||
|
</div>
|
||||||
|
<div className="data-grid-list-filter-options" role="group" aria-label="Allowed values">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<p className="muted small-note">No values are configured for this column.</p>
|
||||||
|
) : options.map((option) => (
|
||||||
|
<div className="data-grid-list-filter-row" key={option.value}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSet.has(option.value)}
|
||||||
|
disabled={option.disabled}
|
||||||
|
onChange={() => toggleOption(option.value)}
|
||||||
|
/>
|
||||||
|
{display === "pill" ? <StatusBadge status={option.value} label={option.label} /> : <span>{option.label}</span>}
|
||||||
|
</label>
|
||||||
|
{editable && (
|
||||||
|
<button type="button" className="data-grid-list-option-remove" aria-label={`Remove ${option.label}`} onClick={() => removeOption(option.value)}>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editable && (
|
||||||
|
<div className="data-grid-list-option-add">
|
||||||
|
<input
|
||||||
|
value={newValue}
|
||||||
|
placeholder="Add value"
|
||||||
|
onChange={(event) => setNewValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
addOption();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" aria-label="Add list value" onClick={addOption} disabled={!newValue.trim()}>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SortIcon({ direction }: { direction?: DataGridSortDirection }) {
|
function SortIcon({ direction }: { direction?: DataGridSortDirection }) {
|
||||||
if (direction === "asc") return <ArrowUp size={14} aria-label="Sorted ascending" />;
|
if (direction === "asc") return <ArrowUp size={14} aria-label="Sorted ascending" />;
|
||||||
if (direction === "desc") return <ArrowDown size={14} aria-label="Sorted descending" />;
|
if (direction === "desc") return <ArrowDown size={14} aria-label="Sorted descending" />;
|
||||||
return <ChevronsUpDown size={14} aria-hidden="true" />;
|
return <ChevronsUpDown size={14} aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCell<T>(column: DataGridColumn<T>, row: T, index: number): ReactNode {
|
||||||
|
if (column.render) return column.render(row, index);
|
||||||
|
if (column.columnType === "from-list" && column.list) {
|
||||||
|
const rawValue = stringifyCell(column.value?.(row, index));
|
||||||
|
const option = column.list.options.find((candidate) => candidate.value === rawValue);
|
||||||
|
const label = option?.label ?? humanizeListValue(rawValue);
|
||||||
|
if (column.list.editable && column.list.onChange) {
|
||||||
|
const cellOptions = option || !rawValue
|
||||||
|
? column.list.options
|
||||||
|
: [...column.list.options, { value: rawValue, label }];
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="data-grid-list-select"
|
||||||
|
value={rawValue}
|
||||||
|
onChange={(event) => column.list?.onChange?.(row, index, event.target.value)}
|
||||||
|
aria-label={`Select ${String(column.header)}`}
|
||||||
|
>
|
||||||
|
{cellOptions.map((candidate) => (
|
||||||
|
<option key={candidate.value} value={candidate.value} disabled={candidate.disabled}>{candidate.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return column.list.display === "pill" ? <StatusBadge status={rawValue} label={label} /> : label;
|
||||||
|
}
|
||||||
|
return stringifyCell(column.value?.(row, index));
|
||||||
|
}
|
||||||
|
|
||||||
function loadState(key: string): DataGridState {
|
function loadState(key: string): DataGridState {
|
||||||
try {
|
try {
|
||||||
const value = window.localStorage.getItem(key);
|
const value = window.localStorage.getItem(key);
|
||||||
@@ -535,6 +777,69 @@ function loadState(key: string): DataGridState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInitialFilters(filters: Record<string, string | string[]>): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(filters).map(([columnId, value]) => [
|
||||||
|
columnId,
|
||||||
|
Array.isArray(value) ? formatListFilter(value) : value,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInitialFilters(state: DataGridState, initialFilters: Record<string, string>): DataGridState {
|
||||||
|
const currentFilters = state.filters ?? {};
|
||||||
|
let changed = false;
|
||||||
|
const nextFilters = { ...currentFilters };
|
||||||
|
for (const [columnId, value] of Object.entries(initialFilters)) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(currentFilters, columnId)) {
|
||||||
|
nextFilters[columnId] = value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return state;
|
||||||
|
return { ...state, filters: nextFilters };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatListFilter(values: string[]): string {
|
||||||
|
return `list:${JSON.stringify([...new Set(values)])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatListSelection(values: string[], options: DataGridListOption[]): string {
|
||||||
|
const optionValues = [...new Set(options.map((option) => option.value))];
|
||||||
|
const selected = [...new Set(values)].filter((value) => optionValues.includes(value));
|
||||||
|
if (optionValues.length > 0 && optionValues.every((value) => selected.includes(value))) return "";
|
||||||
|
return formatListFilter(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListFilter(value: string, allValues: string[]): string[] {
|
||||||
|
if (!value.trim()) return [...allValues];
|
||||||
|
if (value.startsWith("list:")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value.slice(5));
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOptionsForColumn<T>(column: DataGridColumn<T>, rows: T[]): DataGridListOption[] {
|
||||||
|
if (column.columnType !== "from-list") return [];
|
||||||
|
const configured = column.list?.options ?? [];
|
||||||
|
const known = new Set(configured.map((option) => option.value));
|
||||||
|
const observed = rows
|
||||||
|
.map((row, index) => stringifyCell(valueForFilter(column, row, index)).trim())
|
||||||
|
.filter((value) => value && !known.has(value))
|
||||||
|
.map((value) => ({ value, label: humanizeListValue(value) }));
|
||||||
|
return [...configured, ...observed.filter((option, index, all) => all.findIndex((candidate) => candidate.value === option.value) === index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeListValue(value: string): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
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 resizableColumnIds = new Set(columns.filter((column) => column.resizable).map((column) => column.id));
|
||||||
const nextWidths = Object.fromEntries(
|
const nextWidths = Object.fromEntries(
|
||||||
@@ -703,7 +1008,17 @@ function valueForFilter<T>(column: DataGridColumn<T>, row: T, rowIndex: number):
|
|||||||
|
|
||||||
function matchesFilter(value: unknown, filterValue: string, filterType: DataGridFilterType): boolean {
|
function matchesFilter(value: unknown, filterValue: string, filterType: DataGridFilterType): boolean {
|
||||||
if (!filterValue.trim()) return true;
|
if (!filterValue.trim()) return true;
|
||||||
|
if (filterType === "list") {
|
||||||
|
const selected = parseListFilter(filterValue, []);
|
||||||
|
return selected.includes(stringifyCell(value));
|
||||||
|
}
|
||||||
if (filterType === "boolean") {
|
if (filterType === "boolean") {
|
||||||
|
if (filterValue.startsWith("list:")) {
|
||||||
|
const selected = parseListFilter(filterValue, BOOLEAN_FILTER_OPTIONS.map((option) => option.value));
|
||||||
|
const actual = normalizeBoolean(value);
|
||||||
|
if (actual === null) return false;
|
||||||
|
return selected.includes(actual ? "true" : "false");
|
||||||
|
}
|
||||||
const expected = parseBooleanFilter(filterValue);
|
const expected = parseBooleanFilter(filterValue);
|
||||||
if (expected === null) return stringifyCell(value).toLowerCase().includes(filterValue.toLowerCase());
|
if (expected === null) return stringifyCell(value).toLowerCase().includes(filterValue.toLowerCase());
|
||||||
const actual = normalizeBoolean(value);
|
const actual = normalizeBoolean(value);
|
||||||
@@ -809,6 +1124,11 @@ const DATE_OPERATORS: { value: TypedFilterOperator; label: string }[] = [
|
|||||||
{ value: "after", label: "After" }
|
{ value: "after", label: "After" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BOOLEAN_FILTER_OPTIONS: DataGridListOption[] = [
|
||||||
|
{ value: "true", label: "Yes / active" },
|
||||||
|
{ value: "false", label: "No / inactive" }
|
||||||
|
];
|
||||||
|
|
||||||
function compareValues(a: unknown, b: unknown): number {
|
function compareValues(a: unknown, b: unknown): number {
|
||||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||||
const aDate = typeof a === "string" ? Date.parse(a) : NaN;
|
const aDate = typeof a === "string" ? Date.parse(a) : NaN;
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ 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, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { 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, createAttachmentRule, ensureAttachmentBasePaths, nextAttachmentLabel, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
import { countIndividualAttachmentRules, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||||
|
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||||
|
|
||||||
type PathChooserState = { index: number };
|
type PathChooserState = { index: number };
|
||||||
|
|
||||||
@@ -65,17 +66,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBasePath() {
|
function addBasePath(afterIndex = basePaths.length - 1) {
|
||||||
patchBasePaths([...basePaths, createAttachmentBasePath("New attachment source", ".")]);
|
patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", ".")));
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBasePath(index: number) {
|
function removeBasePath(index: number) {
|
||||||
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
|
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addGlobalAttachmentRule() {
|
function moveBasePath(index: number, targetIndex: number) {
|
||||||
if (locked) return;
|
if (locked || index === targetIndex) return;
|
||||||
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules), basePaths[0]?.id ?? "")]);
|
patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -100,24 +101,18 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card title="Attachment sources">
|
||||||
title="Attachment sources"
|
|
||||||
actions={<Button variant="primary" onClick={addBasePath} disabled={locked}>Add base path</Button>}
|
|
||||||
>
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
rows={basePaths}
|
rows={basePaths}
|
||||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser })}
|
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||||
getRowKey={(basePath) => basePath.id}
|
getRowKey={(basePath) => basePath.id}
|
||||||
emptyText="No attachment sources configured."
|
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>}
|
||||||
className="attachment-sources-table-wrap attachment-sources-table"
|
className="attachment-sources-table-wrap attachment-sources-table"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card title="Global Attachments">
|
||||||
title="Global Attachments"
|
|
||||||
actions={<Button variant="primary" onClick={addGlobalAttachmentRule} disabled={locked}>Add file</Button>}
|
|
||||||
>
|
|
||||||
<AttachmentRulesDataGrid
|
<AttachmentRulesDataGrid
|
||||||
id={`campaign-${campaignId}-global-attachments`}
|
id={`campaign-${campaignId}-global-attachments`}
|
||||||
rules={globalRules}
|
rules={globalRules}
|
||||||
@@ -178,11 +173,13 @@ type AttachmentSourceColumnContext = {
|
|||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
fileSpaces: FileSpace[];
|
fileSpaces: FileSpace[];
|
||||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||||
|
addBasePath: (afterIndex?: 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, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, 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 },
|
||||||
{
|
{
|
||||||
@@ -216,7 +213,26 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
|
|||||||
},
|
},
|
||||||
{ 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) => patchBasePath(index, { allow_individual: 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", header: "Actions", width: 120, sticky: "end", render: (_basePath, index) => <Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button> }
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 150,
|
||||||
|
sticky: "end",
|
||||||
|
render: (_basePath, index) => (
|
||||||
|
<DataGridRowActions
|
||||||
|
disabled={locked}
|
||||||
|
removeDisabled={basePaths.length <= 1}
|
||||||
|
onAddBelow={() => addBasePath(index)}
|
||||||
|
onRemove={() => removeBasePath(index)}
|
||||||
|
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
|
||||||
|
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
|
||||||
|
addLabel="Add attachment source below"
|
||||||
|
removeLabel="Remove attachment source"
|
||||||
|
moveUpLabel="Move attachment source up"
|
||||||
|
moveDownLabel="Move attachment source down"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
|
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ 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, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { 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";
|
||||||
|
|
||||||
|
|
||||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
@@ -97,10 +98,11 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addField() {
|
function addField(afterIndex = fields.length - 1) {
|
||||||
const name = uniqueFieldName(fields);
|
const name = uniqueFieldName(fields);
|
||||||
const nextFields = [...fields, { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }];
|
const nextField = { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true };
|
||||||
fieldValueKeys.current = [...fieldValueKeys.current, name];
|
const nextFields = insertAfter(fields, afterIndex, nextField);
|
||||||
|
fieldValueKeys.current = insertAfter(fieldValueKeys.current, afterIndex, name);
|
||||||
const nextGlobalValues = { ...globalValues, [name]: "" };
|
const nextGlobalValues = { ...globalValues, [name]: "" };
|
||||||
setDraft((current) => {
|
setDraft((current) => {
|
||||||
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
|
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
|
||||||
@@ -109,6 +111,12 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveField(index: number, targetIndex: number) {
|
||||||
|
if (locked || index === targetIndex) return;
|
||||||
|
fieldValueKeys.current = moveArrayItem(fieldValueKeys.current, index, targetIndex);
|
||||||
|
patchFields(moveArrayItem(fields, index, targetIndex));
|
||||||
|
}
|
||||||
|
|
||||||
function deleteField(index: number) {
|
function deleteField(index: number) {
|
||||||
const field = fields[index];
|
const field = fields[index];
|
||||||
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
|
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
|
||||||
@@ -163,16 +171,13 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card title="Fields and global values">
|
||||||
title="Fields and global values"
|
|
||||||
actions={<Button variant="primary" onClick={addField} disabled={locked}>Add field</Button>}
|
|
||||||
>
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-fields`}
|
id={`campaign-${campaignId}-fields`}
|
||||||
rows={fields}
|
rows={fields}
|
||||||
columns={fieldColumns({ locked, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, 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="No campaign fields configured yet."
|
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>}
|
||||||
className="field-editor-table-wrap field-editor-table"
|
className="field-editor-table-wrap field-editor-table"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -188,15 +193,18 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
type FieldColumnContext = {
|
type FieldColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
fields: CampaignFieldDefinition[];
|
||||||
globalValues: Record<string, unknown>;
|
globalValues: Record<string, unknown>;
|
||||||
renameField: (index: number, nextName: string) => void;
|
renameField: (index: number, nextName: string) => void;
|
||||||
setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => void;
|
setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => void;
|
||||||
setGlobalValue: (key: string, value: unknown) => void;
|
setGlobalValue: (key: string, value: unknown) => void;
|
||||||
setOverrideAllowed: (index: number, allowed: boolean) => void;
|
setOverrideAllowed: (index: number, allowed: boolean) => void;
|
||||||
|
addField: (afterIndex?: number) => void;
|
||||||
|
moveField: (index: number, targetIndex: number) => void;
|
||||||
deleteField: (index: number) => void;
|
deleteField: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function fieldColumns({ locked, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
|
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
||||||
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
||||||
@@ -204,7 +212,25 @@ function fieldColumns({ locked, globalValues, renameField, setField, setGlobalVa
|
|||||||
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
||||||
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
||||||
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
|
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
|
||||||
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_field, index) => <Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Remove</Button> }
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 150,
|
||||||
|
sticky: "end",
|
||||||
|
render: (_field, index) => (
|
||||||
|
<DataGridRowActions
|
||||||
|
disabled={locked}
|
||||||
|
onAddBelow={() => addField(index)}
|
||||||
|
onRemove={() => deleteField(index)}
|
||||||
|
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
||||||
|
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
||||||
|
addLabel="Add field below"
|
||||||
|
removeLabel="Remove field"
|
||||||
|
moveUpLabel="Move field up"
|
||||||
|
moveDownLabel="Move field down"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,32 @@ import Card from "../../components/Card";
|
|||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import MetricCard from "../../components/MetricCard";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import { publishCampaignVersion, updateCampaignMetadata, type CampaignVersionListItem } from "../../api/campaigns";
|
import {
|
||||||
|
lockCampaignVersionPermanently,
|
||||||
|
lockCampaignVersionTemporarily,
|
||||||
|
unlockCampaignVersionUserLock,
|
||||||
|
updateCampaignMetadata,
|
||||||
|
type CampaignVersionListItem,
|
||||||
|
} from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView";
|
import {
|
||||||
|
canUnlockValidationVersion,
|
||||||
|
formatDateTime,
|
||||||
|
isFinalLockedVersion,
|
||||||
|
isPermanentUserLockedVersion,
|
||||||
|
isTemporaryUserLockedVersion,
|
||||||
|
isVersionReadyForDelivery,
|
||||||
|
summaryValue,
|
||||||
|
} from "./utils/campaignView";
|
||||||
|
|
||||||
const campaignModeOptions = ["draft", "test", "send"];
|
const campaignModeOptions = ["draft", "test", "send"];
|
||||||
|
type LockAction = "temporary" | "unlock" | "permanent";
|
||||||
|
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
|
||||||
|
|
||||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignOverviewPage({ 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 });
|
||||||
@@ -23,7 +40,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
||||||
const [identityDirty, setIdentityDirty] = useState(false);
|
const [identityDirty, setIdentityDirty] = useState(false);
|
||||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||||
const [lockingVersion, setLockingVersion] = useState<CampaignVersionListItem | null>(null);
|
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||||
const [lockBusy, setLockBusy] = useState(false);
|
const [lockBusy, setLockBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
@@ -33,7 +50,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
external_id: campaign.external_id ?? "",
|
external_id: campaign.external_id ?? "",
|
||||||
name: campaign.name ?? "",
|
name: campaign.name ?? "",
|
||||||
status: campaign.status ?? "",
|
status: campaign.status ?? "",
|
||||||
description: campaign.description ?? ""
|
description: campaign.description ?? "",
|
||||||
});
|
});
|
||||||
}, [campaign, identityDirty]);
|
}, [campaign, identityDirty]);
|
||||||
|
|
||||||
@@ -53,7 +70,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
external_id: identity.external_id,
|
external_id: identity.external_id,
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
status: identity.status,
|
status: identity.status,
|
||||||
description: identity.description
|
description: identity.description,
|
||||||
});
|
});
|
||||||
setIdentityDirty(false);
|
setIdentityDirty(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -64,16 +81,24 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lockAuditSnapshot() {
|
async function applyLockAction() {
|
||||||
const version = lockingVersion;
|
const pending = pendingLockAction;
|
||||||
if (!version || lockBusy) return;
|
if (!pending || lockBusy) return;
|
||||||
setLockBusy(true);
|
setLockBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
try {
|
try {
|
||||||
await publishCampaignVersion(settings, campaignId, version.id);
|
if (pending.action === "temporary") {
|
||||||
setMessage(`Version #${version.version_number} locked as audit-safe snapshot.`);
|
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
|
||||||
setLockingVersion(null);
|
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
|
||||||
|
} else if (pending.action === "unlock") {
|
||||||
|
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
|
||||||
|
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
|
||||||
|
} else {
|
||||||
|
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
|
||||||
|
setMessage(`Version #${pending.version.version_number} permanently locked.`);
|
||||||
|
}
|
||||||
|
setPendingLockAction(null);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -100,6 +125,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||||
|
<div className="metric-grid campaign-overview-metrics">
|
||||||
|
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
||||||
|
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
||||||
|
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
||||||
|
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="Campaign identity">
|
<Card title="Campaign identity">
|
||||||
<div className="form-grid campaign-identity-grid">
|
<div className="form-grid campaign-identity-grid">
|
||||||
<FormField label="Campaign ID">
|
<FormField label="Campaign ID">
|
||||||
@@ -123,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(setLockingVersion)}
|
columns={versionColumns(setPendingLockAction)}
|
||||||
getRowKey={(version) => version.id}
|
getRowKey={(version) => version.id}
|
||||||
emptyText="No versions found."
|
emptyText="No versions found."
|
||||||
className="version-history-table"
|
className="version-history-table"
|
||||||
@@ -142,50 +174,55 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(lockingVersion)}
|
open={Boolean(pendingLockAction)}
|
||||||
title="Lock audit-safe snapshot?"
|
title={lockDialogTitle(pendingLockAction)}
|
||||||
message="This is a user-requested final lock. The version cannot be validated, unlocked, built, dry-run or sent afterwards. Create an editable copy for future changes."
|
message={lockDialogMessage(pendingLockAction)}
|
||||||
confirmLabel="Lock snapshot"
|
confirmLabel={lockDialogLabel(pendingLockAction)}
|
||||||
tone="danger"
|
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
|
||||||
busy={lockBusy}
|
busy={lockBusy}
|
||||||
onCancel={() => setLockingVersion(null)}
|
onCancel={() => setPendingLockAction(null)}
|
||||||
onConfirm={() => void lockAuditSnapshot()}
|
onConfirm={() => void applyLockAction()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionColumns(setLockingVersion: (version: CampaignVersionListItem) => void): DataGridColumn<CampaignVersionListItem>[] {
|
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void): 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: 170, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel },
|
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel },
|
||||||
{ 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 ?? "" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
width: 190,
|
width: 310,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (version) => (
|
render: (version) => (
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link>
|
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link>
|
||||||
<Button
|
{isTemporaryUserLockedVersion(version) ? (
|
||||||
onClick={() => setLockingVersion(version)}
|
<>
|
||||||
disabled={isUserLockedVersion(version) || isFinalLockedVersion(version) || canUnlockValidationVersion(version)}
|
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
||||||
>Lock</Button>
|
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
||||||
|
</>
|
||||||
|
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
||||||
|
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionLockLabel(version: CampaignVersionListItem): string {
|
function versionLockLabel(version: CampaignVersionListItem): string {
|
||||||
if (isUserLockedVersion(version)) return "User locked";
|
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
|
||||||
if (isFinalLockedVersion(version)) return "Final";
|
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
||||||
if (canUnlockValidationVersion(version)) return "Validation lock";
|
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
||||||
if (version.locked_at) return "Locked";
|
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
|
||||||
|
if (version.locked_at) return "Temporarily locked";
|
||||||
return "Editable";
|
return "Editable";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +230,7 @@ function validationLabel(version: CampaignVersionListItem): string {
|
|||||||
const validation = version.validation_summary ?? {};
|
const validation = version.validation_summary ?? {};
|
||||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
||||||
if (validation.ok === false) return "Needs attention";
|
if (validation.ok === false) return "Needs attention";
|
||||||
if (validation.ok === true) return "Invalid for delivery";
|
if (validation.ok === true) return "Passed, unavailable while user-locked";
|
||||||
return "Not validated";
|
return "Not validated";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +239,33 @@ function buildLabel(version: CampaignVersionListItem): string {
|
|||||||
return String(build.built_count ?? build.ready_count ?? "Not built");
|
return String(build.built_count ?? build.ready_count ?? "Not built");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lockDialogTitle(pending: PendingLockAction): string {
|
||||||
|
if (pending?.action === "temporary") return "Temporarily lock version?";
|
||||||
|
if (pending?.action === "unlock") return "Unlock version?";
|
||||||
|
if (pending?.action === "permanent") return "Lock version permanently?";
|
||||||
|
return "Confirm lock action";
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockDialogMessage(pending: PendingLockAction): string {
|
||||||
|
if (pending?.action === "temporary") {
|
||||||
|
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
|
||||||
|
}
|
||||||
|
if (pending?.action === "unlock") {
|
||||||
|
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
|
||||||
|
}
|
||||||
|
if (pending?.action === "permanent") {
|
||||||
|
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
|
||||||
|
}
|
||||||
|
return "Continue?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockDialogLabel(pending: PendingLockAction): string {
|
||||||
|
if (pending?.action === "temporary") return "Lock temporarily";
|
||||||
|
if (pending?.action === "unlock") return "Unlock";
|
||||||
|
if (pending?.action === "permanent") return "Lock permanently";
|
||||||
|
return "Confirm";
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
<div className="summary-tile">
|
<div className="summary-tile">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
|
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
|
||||||
import SectionSidebar from "../../layout/SectionSidebar";
|
import SectionSidebar from "../../layout/SectionSidebar";
|
||||||
@@ -50,11 +51,32 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
|
|||||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
const { requestNavigation } = useCampaignUnsavedChanges();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const active = sectionFromPath(location.pathname);
|
const active = sectionFromPath(location.pathname);
|
||||||
|
const urlVersionId = new URLSearchParams(location.search).get("version");
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(urlVersionId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlVersionId) setSelectedVersionId(urlVersionId);
|
||||||
|
}, [urlVersionId]);
|
||||||
|
|
||||||
|
// Relative links inside the workspace historically dropped ?version=. Keep
|
||||||
|
// the selected version stable for the lifetime of this campaign workspace,
|
||||||
|
// while still opening the latest version on a fresh campaign entry.
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlVersionId || !selectedVersionId) return;
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.set("version", selectedVersionId);
|
||||||
|
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||||
|
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
|
||||||
|
|
||||||
function select(section: CampaignWorkspaceSection) {
|
function select(section: CampaignWorkspaceSection) {
|
||||||
const path = sectionPaths[section];
|
const path = sectionPaths[section];
|
||||||
const target = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
|
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
|
||||||
if (location.pathname === target) return;
|
const params = new URLSearchParams(location.search);
|
||||||
|
const versionId = urlVersionId ?? selectedVersionId;
|
||||||
|
if (versionId) params.set("version", versionId);
|
||||||
|
const query = params.toString();
|
||||||
|
const target = query ? `${pathname}?${query}` : pathname;
|
||||||
|
if (`${location.pathname}${location.search}` === target) return;
|
||||||
requestNavigation(() => navigate(target));
|
requestNavigation(() => navigate(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { 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";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
collectCampaignAddressSuggestions,
|
collectCampaignAddressSuggestions,
|
||||||
type MailboxAddress
|
type MailboxAddress
|
||||||
} from "../../utils/emailAddresses";
|
} from "../../utils/emailAddresses";
|
||||||
|
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||||
|
|
||||||
const recipientHeaderRows = [
|
const recipientHeaderRows = [
|
||||||
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
|
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
|
||||||
@@ -79,7 +80,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
patch(["entries", "inline"], nextEntries);
|
patch(["entries", "inline"], nextEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRecipient() {
|
function addRecipient(afterIndex = inlineEntries.length - 1) {
|
||||||
const nextIndex = inlineEntries.length + 1;
|
const nextIndex = inlineEntries.length + 1;
|
||||||
const newEntry = {
|
const newEntry = {
|
||||||
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
|
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
|
||||||
@@ -92,7 +93,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
from: {},
|
from: {},
|
||||||
reply_to: []
|
reply_to: []
|
||||||
};
|
};
|
||||||
replaceInlineEntries([...inlineEntries, newEntry]);
|
replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||||
@@ -122,6 +123,11 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveEntry(index: number, targetIndex: number) {
|
||||||
|
if (locked || index === targetIndex) return;
|
||||||
|
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
@@ -222,19 +228,18 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="Recipient profiles"
|
title="Recipient profiles"
|
||||||
actions={[<Button key="import" disabled>Import</Button>, <Button key="add" variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
|
actions={<Button disabled>Import</Button>}
|
||||||
>
|
>
|
||||||
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet.</p>}
|
|
||||||
{inlineEntries.length === 0 && Boolean(source.type) && (
|
{inlineEntries.length === 0 && Boolean(source.type) && (
|
||||||
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
|
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
|
||||||
)}
|
)}
|
||||||
{inlineEntries.length > 0 && (
|
{!source.type && (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-recipient-profiles`}
|
id={`campaign-${campaignId}-recipient-profiles`}
|
||||||
rows={inlineEntries.slice(0, 100)}
|
rows={inlineEntries.slice(0, 100)}
|
||||||
columns={recipientProfileColumns({ locked, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, 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="No recipient profiles are stored in the current version yet."
|
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>}
|
||||||
className="recipient-table-wrap recipient-address-table"
|
className="recipient-table-wrap recipient-address-table"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -251,14 +256,17 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
type RecipientProfileColumnContext = {
|
type RecipientProfileColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
entries: Record<string, unknown>[];
|
||||||
entryAddressColumns: EntryAddressColumn[];
|
entryAddressColumns: EntryAddressColumn[];
|
||||||
addressSuggestions: MailboxAddress[];
|
addressSuggestions: MailboxAddress[];
|
||||||
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
||||||
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||||
|
addRecipient: (afterIndex?: number) => void;
|
||||||
|
moveEntry: (index: number, targetIndex: number) => void;
|
||||||
removeEntry: (index: number) => void;
|
removeEntry: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function recipientProfileColumns({ locked, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
function recipientProfileColumns({ locked, entries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||||
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
|
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
|
||||||
@@ -282,7 +290,25 @@ function recipientProfileColumns({ locked, entryAddressColumns, addressSuggestio
|
|||||||
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
|
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
|
||||||
})),
|
})),
|
||||||
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
||||||
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_entry, index) => <Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button> }
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 150,
|
||||||
|
sticky: "end",
|
||||||
|
render: (_entry, index) => (
|
||||||
|
<DataGridRowActions
|
||||||
|
disabled={locked}
|
||||||
|
onAddBelow={() => addRecipient(index)}
|
||||||
|
onRemove={() => removeEntry(index)}
|
||||||
|
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
|
||||||
|
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
|
||||||
|
addLabel="Add recipient below"
|
||||||
|
removeLabel="Remove recipient"
|
||||||
|
moveUpLabel="Move recipient up"
|
||||||
|
moveDownLabel="Move recipient down"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MailSearch,
|
MailSearch,
|
||||||
@@ -14,16 +16,24 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns";
|
import {
|
||||||
|
buildVersion,
|
||||||
|
getCampaignJobs,
|
||||||
|
mockSendCampaign,
|
||||||
|
updateCampaignReviewState,
|
||||||
|
validateVersion,
|
||||||
|
type CampaignVersionDetail,
|
||||||
|
} from "../../api/campaigns";
|
||||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import InlineHelp from "../../components/help/InlineHelp";
|
import InlineHelp from "../../components/help/InlineHelp";
|
||||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +86,19 @@ const stateColors: Record<FlowState, string> = {
|
|||||||
pending: "var(--muted)",
|
pending: "var(--muted)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
||||||
|
{ value: "ready", label: "Ready" },
|
||||||
|
{ value: "warning", label: "Warning" },
|
||||||
|
{ value: "needs_review", label: "Needs review" },
|
||||||
|
{ value: "blocked", label: "Blocked" },
|
||||||
|
{ value: "excluded", label: "Excluded" },
|
||||||
|
{ value: "inactive", label: "Inactive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGE_REVIEW_DEFAULT_FILTER = MESSAGE_VALIDATION_OPTIONS
|
||||||
|
.filter((option) => option.value !== "ready")
|
||||||
|
.map((option) => option.value);
|
||||||
|
|
||||||
export default function ReviewSendDevelopmentPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function ReviewSendDevelopmentPage({ 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;
|
||||||
@@ -93,21 +116,28 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
const [busy, setBusy] = useState<WorkflowBusy>("");
|
const [busy, setBusy] = useState<WorkflowBusy>("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [messageReviewComplete, setMessageReviewComplete] = useState(false);
|
const [messageReviewComplete, setMessageReviewComplete] = useState(false);
|
||||||
const [reviewResult, setReviewResult] = useState<Record<string, unknown> | null>(null);
|
const [builtReviewRows, setBuiltReviewRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [jobsLoadedVersionId, setJobsLoadedVersionId] = useState("");
|
||||||
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
|
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
|
||||||
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
|
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
|
||||||
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
|
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
|
||||||
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
||||||
|
const persistedReview = storedMessageReviewState(version);
|
||||||
|
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessageReviewComplete(false);
|
setBuiltReviewRows([]);
|
||||||
setReviewResult(null);
|
setJobsLoadedVersionId("");
|
||||||
setReviewedMessageKeys(new Set());
|
|
||||||
setSelectedBuiltIndex(null);
|
setSelectedBuiltIndex(null);
|
||||||
setMockResult(null);
|
setMockResult(null);
|
||||||
setSelectedMockMessage(null);
|
setSelectedMockMessage(null);
|
||||||
}, [version?.id]);
|
}, [version?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessageReviewComplete(persistedReview.inspectionComplete);
|
||||||
|
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
|
||||||
|
}, [version?.id, persistedReviewKey]);
|
||||||
|
|
||||||
const validationPresent = Object.keys(validation).length > 0;
|
const validationPresent = Object.keys(validation).length > 0;
|
||||||
const validationOk = validation.ok === true;
|
const validationOk = validation.ok === true;
|
||||||
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
|
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
|
||||||
@@ -122,6 +152,11 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
|
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
|
||||||
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built");
|
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!version?.id || !hasBuild || jobsLoadedVersionId === version.id) return;
|
||||||
|
void loadBuiltMessages(true);
|
||||||
|
}, [version?.id, hasBuild, jobsLoadedVersionId]);
|
||||||
|
|
||||||
const jobsTotal = cards?.jobs_total ?? inlineEntries.length;
|
const jobsTotal = cards?.jobs_total ?? inlineEntries.length;
|
||||||
const sentCount = cards?.sent ?? 0;
|
const sentCount = cards?.sent ?? 0;
|
||||||
const failedCount = cards?.failed ?? 0;
|
const failedCount = cards?.failed ?? 0;
|
||||||
@@ -133,10 +168,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
const finalVersion = isFinalLockedVersion(version);
|
const finalVersion = isFinalLockedVersion(version);
|
||||||
const userLockedVersion = isUserLockedVersion(version);
|
const userLockedVersion = isUserLockedVersion(version);
|
||||||
|
|
||||||
const reviewBuild = asRecord(reviewResult?.build);
|
|
||||||
const builtReviewRows = asArray(reviewBuild.messages).map(asRecord);
|
|
||||||
const reviewedCount = builtReviewRows.reduce((count, row, index) => count + (reviewedMessageKeys.has(builtMessageKey(row, index)) ? 1 : 0), 0);
|
|
||||||
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
||||||
|
const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview);
|
||||||
|
const reviewedRequiredCount = reviewRequiredRows.reduce(
|
||||||
|
(count, row) => count + (reviewedMessageKeys.has(builtMessageKey(row, builtReviewRows.indexOf(row))) ? 1 : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const automaticInspectionComplete = builtReviewRows.length > 0 && reviewRequiredRows.length === 0;
|
||||||
|
|
||||||
const mockSend = asRecord(mockResult?.send);
|
const mockSend = asRecord(mockResult?.send);
|
||||||
const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
|
const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
|
||||||
@@ -183,7 +221,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
: "active";
|
: "active";
|
||||||
|
|
||||||
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
||||||
const inspectionSatisfied = messageReviewComplete || downstreamDeliveryActivity;
|
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
|
||||||
|
|
||||||
const inspectState: FlowState = !hasBuild
|
const inspectState: FlowState = !hasBuild
|
||||||
? "locked"
|
? "locked"
|
||||||
@@ -336,8 +374,9 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await buildVersion(settings, version.id, true);
|
const result = await buildVersion(settings, version.id, true);
|
||||||
const review = await requestBuiltMessageReview();
|
const review = await getCampaignJobs(settings, campaignId, version.id);
|
||||||
setReviewResult(review);
|
setBuiltReviewRows(review.jobs.map(asRecord));
|
||||||
|
setJobsLoadedVersionId(version.id);
|
||||||
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`);
|
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`);
|
||||||
setMessageReviewComplete(false);
|
setMessageReviewComplete(false);
|
||||||
setReviewedMessageKeys(new Set());
|
setReviewedMessageKeys(new Set());
|
||||||
@@ -352,39 +391,27 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBuiltMessages() {
|
async function loadBuiltMessages(silent = false) {
|
||||||
if (!version || busy || !hasBuild) return;
|
if (!version || busy || !hasBuild) return;
|
||||||
setBusy("inspect");
|
setBusy("inspect");
|
||||||
setMessage("Loading the built-message review…");
|
if (!silent) setMessage("Loading the built-message review…");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await requestBuiltMessageReview();
|
const result = await getCampaignJobs(settings, campaignId, version.id);
|
||||||
setReviewResult(result);
|
const jobs = result.jobs.map(asRecord);
|
||||||
setMessage(`Loaded ${asArray(asRecord(result.build).messages).length} built message(s) for inspection.`);
|
setBuiltReviewRows(jobs);
|
||||||
|
setJobsLoadedVersionId(version.id);
|
||||||
|
if (!silent) setMessage(`Loaded ${jobs.length} built message(s) for inspection.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage("");
|
if (!silent) setMessage("");
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestBuiltMessageReview(): Promise<Record<string, unknown>> {
|
|
||||||
if (!version) throw new Error("No campaign version is available.");
|
|
||||||
const response = await mockSendCampaign(settings, campaignId, {
|
|
||||||
version_id: version.id,
|
|
||||||
send: false,
|
|
||||||
include_warnings: true,
|
|
||||||
include_needs_review: true,
|
|
||||||
append_sent: false,
|
|
||||||
clear_mailbox: false,
|
|
||||||
check_files: true,
|
|
||||||
});
|
|
||||||
return asRecord(response.result ?? response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runMockSend() {
|
async function runMockSend() {
|
||||||
if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return;
|
if (!version || busy || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
||||||
setBusy("mock");
|
setBusy("mock");
|
||||||
setMessage("Running the complete mock-delivery flow…");
|
setMessage("Running the complete mock-delivery flow…");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -411,6 +438,27 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function completeInspection() {
|
||||||
|
if (!version || busy || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
||||||
|
setBusy("inspect");
|
||||||
|
setError("");
|
||||||
|
setMessage("Recording the completed message review…");
|
||||||
|
try {
|
||||||
|
await updateCampaignReviewState(settings, campaignId, version.id, {
|
||||||
|
inspection_complete: true,
|
||||||
|
reviewed_message_keys: [...reviewedMessageKeys],
|
||||||
|
});
|
||||||
|
setMessageReviewComplete(true);
|
||||||
|
setMessage("Message review completed and recorded for this build.");
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setMessage("");
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openMockMessage(id: string) {
|
async function openMockMessage(id: string) {
|
||||||
if (!id || busy === "mailbox") return;
|
if (!id || busy === "mailbox") return;
|
||||||
setBusy("mailbox");
|
setBusy("mailbox");
|
||||||
@@ -438,7 +486,8 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
|
|
||||||
function resetDownstreamReview() {
|
function resetDownstreamReview() {
|
||||||
setMessageReviewComplete(false);
|
setMessageReviewComplete(false);
|
||||||
setReviewResult(null);
|
setBuiltReviewRows([]);
|
||||||
|
setJobsLoadedVersionId("");
|
||||||
setReviewedMessageKeys(new Set());
|
setReviewedMessageKeys(new Set());
|
||||||
setSelectedBuiltIndex(null);
|
setSelectedBuiltIndex(null);
|
||||||
setMockResult(null);
|
setMockResult(null);
|
||||||
@@ -454,7 +503,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
|
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
|
||||||
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
|
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
|
||||||
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
|
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
|
||||||
const canCompleteInspection = builtReviewRows.length > 0 && reviewedCount > 0;
|
const canCompleteInspection = reviewRequiredRows.length > 0 && reviewedRequiredCount === reviewRequiredRows.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page review-flow-development-page">
|
<div className="content-pad workspace-data-page review-flow-development-page">
|
||||||
@@ -478,6 +527,16 @@ 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) && (
|
||||||
|
<LockedVersionNotice
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
|
version={version}
|
||||||
|
reload={reload}
|
||||||
|
message={readyForDelivery ? "Locked and validated. Unlock validation to edit campaign data, or create an editable copy." : "Send snapshot. Copy to edit."}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading workflow state…">
|
<LoadingFrame loading={loading} label="Loading workflow state…">
|
||||||
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
|
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
|
||||||
|
|
||||||
@@ -499,10 +558,8 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
{busy === "validate"
|
{busy === "validate"
|
||||||
? "Validating…"
|
? "Validating…"
|
||||||
: readyForDelivery
|
: readyForDelivery
|
||||||
? "Validated and locked"
|
? "Locked and validated"
|
||||||
: validationPresent
|
: "Lock and validate"}
|
||||||
? "Validate again"
|
|
||||||
: "Validate campaign"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
@@ -545,7 +602,8 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
<div className="review-flow-fact-grid">
|
<div className="review-flow-fact-grid">
|
||||||
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
|
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
|
||||||
<WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
|
<WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
|
||||||
<WorkflowFact label="Opened" value={reviewedCount} />
|
<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>
|
||||||
<div className="button-row compact-actions review-flow-stage-actions">
|
<div className="button-row compact-actions review-flow-stage-actions">
|
||||||
@@ -555,12 +613,15 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
<Link className="btn btn-secondary" to="../template">Open template editor</Link>
|
<Link className="btn btn-secondary" to="../template">Open template editor</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => setMessageReviewComplete((value) => !value)}
|
onClick={() => void completeInspection()}
|
||||||
disabled={!canCompleteInspection || downstreamDeliveryActivity}
|
disabled={automaticInspectionComplete || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
|
||||||
>
|
>
|
||||||
{messageReviewComplete ? "Inspection completed" : "Complete inspection"}
|
{automaticInspectionComplete || messageReviewComplete ? "Inspection completed" : "Complete inspection"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
{builtReviewRows.length > 0 && (
|
{builtReviewRows.length > 0 && (
|
||||||
<div className="review-flow-data-section">
|
<div className="review-flow-data-section">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
@@ -568,10 +629,11 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|||||||
rows={builtReviewRows}
|
rows={builtReviewRows}
|
||||||
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
|
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
|
||||||
getRowKey={builtMessageKey}
|
getRowKey={builtMessageKey}
|
||||||
emptyText="No built messages are available for review."
|
initialFilters={{ validation: MESSAGE_REVIEW_DEFAULT_FILTER }}
|
||||||
|
emptyText="No messages require review. Ready messages are hidden by the default validation filter."
|
||||||
className="data-table-wrap data-table compact-table"
|
className="data-table-wrap data-table compact-table"
|
||||||
/>
|
/>
|
||||||
<p className="muted small-note">Open at least one message before completing the inspection step. The preview includes rendered content, recipients, issues and exact attachment metadata.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
@@ -584,7 +646,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) || !messageReviewComplete || deliveryQueued || deliveryStarted}>
|
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !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>
|
||||||
@@ -733,6 +795,7 @@ function WorkflowStage({
|
|||||||
}) {
|
}) {
|
||||||
const Icon = stage.icon;
|
const Icon = stage.icon;
|
||||||
const locked = stage.state === "locked";
|
const locked = stage.state === "locked";
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const style = {
|
const style = {
|
||||||
"--review-stage-color": stateColors[stage.state],
|
"--review-stage-color": stateColors[stage.state],
|
||||||
"--review-next-stage-color": stateColors[nextState ?? stage.state],
|
"--review-next-stage-color": stateColors[nextState ?? stage.state],
|
||||||
@@ -744,30 +807,46 @@ 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" : ""}`} aria-disabled={locked || undefined}>
|
<article className={`card 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>
|
<InlineHelp>{stage.description}</InlineHelp>
|
||||||
</h2>
|
</h2>
|
||||||
{!locked && (
|
<div className="review-flow-stage-header-actions">
|
||||||
<span
|
{!locked && (
|
||||||
className="review-flow-state-badge"
|
<span
|
||||||
data-state={stage.state}
|
className="review-flow-state-badge"
|
||||||
aria-label={stage.stateLabel}
|
data-state={stage.state}
|
||||||
title={stage.stateLabel}
|
aria-label={stage.stateLabel}
|
||||||
|
title={stage.stateLabel}
|
||||||
|
>
|
||||||
|
{stage.stateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="review-flow-collapse-button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`}
|
||||||
|
title={collapsed ? "Expand card" : "Collapse card"}
|
||||||
|
onClick={() => setCollapsed((value) => !value)}
|
||||||
>
|
>
|
||||||
{stage.stateLabel}
|
{collapsed ? <ChevronRight size={18} aria-hidden="true" /> : <ChevronDown size={18} aria-hidden="true" />}
|
||||||
</span>
|
</button>
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<div className="card-body review-flow-stage-content">{children}</div>
|
|
||||||
{locked && (
|
|
||||||
<div className="review-flow-lock-message">
|
|
||||||
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
|
|
||||||
<span>{stage.lockReason}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<div className="card-body review-flow-stage-content">{children}</div>
|
||||||
|
{locked && (
|
||||||
|
<div className="review-flow-lock-message">
|
||||||
|
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
|
||||||
|
<span>{stage.lockReason}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -799,6 +878,7 @@ function BuiltMessagePreview({
|
|||||||
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
|
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
|
||||||
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject");
|
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject");
|
||||||
const issues = asArray(row.issues).map(asRecord);
|
const issues = asArray(row.issues).map(asRecord);
|
||||||
|
const resolvedRecipients = asRecord(row.resolved_recipients);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePreviewOverlay
|
<MessagePreviewOverlay
|
||||||
@@ -807,7 +887,7 @@ function BuiltMessagePreview({
|
|||||||
bodyMode={html.trim() ? "html" : "text"}
|
bodyMode={html.trim() ? "html" : "text"}
|
||||||
text={text}
|
text={text}
|
||||||
html={html}
|
html={html}
|
||||||
recipientLabel={formatAddressList(row.to) || `Message ${index + 1}`}
|
recipientLabel={formatAddressList(resolvedRecipients.to) || String(row.recipient_email ?? `Message ${index + 1}`)}
|
||||||
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."}
|
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."}
|
||||||
metaItems={builtMessageMetaItems(row)}
|
metaItems={builtMessageMetaItems(row)}
|
||||||
attachments={builtMessageAttachments(row)}
|
attachments={builtMessageAttachments(row)}
|
||||||
@@ -839,19 +919,34 @@ function builtMessageColumns(
|
|||||||
): DataGridColumn<Record<string, unknown>>[] {
|
): DataGridColumn<Record<string, unknown>>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
|
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
|
||||||
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || "—" },
|
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
|
||||||
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||||
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
{
|
||||||
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? asArray(row.attachments).length) },
|
id: "validation",
|
||||||
|
header: "Validation",
|
||||||
|
width: 145,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
columnType: "from-list",
|
||||||
|
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
|
||||||
|
value: (row) => String(row.validation_status ?? "unknown"),
|
||||||
|
},
|
||||||
|
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(countResolvedAttachments(row.attachments)) },
|
||||||
{ id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, render: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? <Check size={17} aria-label="Reviewed" /> : <span className="muted">—</span>, value: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? "yes" : "no" },
|
{ id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, render: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? <Check size={17} aria-label="Reviewed" /> : <span className="muted">—</span>, value: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? "yes" : "no" },
|
||||||
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>Review</Button> },
|
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>Review</Button> },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
||||||
|
const options: DataGridListOption[] = [
|
||||||
|
{ value: "sent", label: "Sent" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "skipped", label: "Skipped" },
|
||||||
|
{ value: "ready", label: "Ready" },
|
||||||
|
];
|
||||||
return [
|
return [
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
|
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
|
||||||
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
|
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") },
|
||||||
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
|
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
|
||||||
{ id: "smtp", header: "SMTP", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
|
{ id: "smtp", header: "SMTP", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
|
||||||
{ id: "imap", header: "IMAP", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
|
{ id: "imap", header: "IMAP", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
|
||||||
@@ -860,8 +955,12 @@ function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
|
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
|
||||||
|
const options: DataGridListOption[] = [
|
||||||
|
{ value: "smtp", label: "SMTP" },
|
||||||
|
{ value: "imap_append", label: "IMAP append" },
|
||||||
|
];
|
||||||
return [
|
return [
|
||||||
{ id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", render: (row) => <StatusBadge status={String(row.kind ?? "mock")} />, value: (row) => String(row.kind ?? "mock") },
|
{ id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") },
|
||||||
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||||
{ id: "envelope", header: "Envelope / folder", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")} → ${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
|
{ id: "envelope", header: "Envelope / folder", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")} → ${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
|
||||||
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? 0) },
|
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? 0) },
|
||||||
@@ -870,25 +969,44 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function builtMessageMetaItems(row: Record<string, unknown>) {
|
function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||||
|
const recipients = asRecord(row.resolved_recipients);
|
||||||
return [
|
return [
|
||||||
{ label: "From", value: formatSingleAddress(row.from) || "—" },
|
{ label: "From", value: formatSingleAddress(recipients.from) || "—" },
|
||||||
{ label: "To", value: formatAddressList(row.to) || "—" },
|
{ label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
|
||||||
{ label: "CC", value: formatAddressList(row.cc) || "—" },
|
{ label: "CC", value: formatAddressList(recipients.cc) || "—" },
|
||||||
{ label: "BCC", value: formatAddressList(row.bcc) || "—" },
|
{ label: "BCC", value: formatAddressList(recipients.bcc) || "—" },
|
||||||
{ label: "Validation", value: String(row.validation_status ?? "—") },
|
{ label: "Validation", value: String(row.validation_status ?? "—") },
|
||||||
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
|
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] {
|
function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] {
|
||||||
return asArray(row.attachments).map((value, index) => {
|
return asArray(row.attachments).flatMap((value, index) => {
|
||||||
const attachment = asRecord(value);
|
const attachment = asRecord(value);
|
||||||
return {
|
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||||
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? `Attachment ${index + 1}`),
|
if (managedMatches.length > 0) {
|
||||||
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.label ?? ""),
|
return managedMatches.map((match, matchIndex) => ({
|
||||||
|
filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`),
|
||||||
|
detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""),
|
||||||
|
contentType: stringOrUndefined(match.content_type),
|
||||||
|
sizeBytes: numberOrUndefined(match.size_bytes),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
|
||||||
|
if (matches.length > 0) {
|
||||||
|
return matches.map((match) => ({
|
||||||
|
filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`),
|
||||||
|
detail: match,
|
||||||
|
contentType: stringOrUndefined(attachment.content_type),
|
||||||
|
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`),
|
||||||
|
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""),
|
||||||
contentType: stringOrUndefined(attachment.content_type),
|
contentType: stringOrUndefined(attachment.content_type),
|
||||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||||
};
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +1029,39 @@ function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAtta
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countResolvedAttachments(value: unknown): number {
|
||||||
|
return asArray(value).reduce<number>((count, item) => {
|
||||||
|
const attachment = asRecord(item);
|
||||||
|
const managedCount = asArray(attachment.managed_matches).length;
|
||||||
|
if (managedCount > 0) return count + managedCount;
|
||||||
|
const matchCount = asArray(attachment.matches).length;
|
||||||
|
return count + (matchCount > 0 ? matchCount : 1);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRequiresReview(row: Record<string, unknown>): boolean {
|
||||||
|
const status = String(row.validation_status ?? "").toLowerCase();
|
||||||
|
return status !== "ready" || asArray(row.issues).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||||
|
buildToken: string;
|
||||||
|
inspectionComplete: boolean;
|
||||||
|
reviewedMessageKeys: string[];
|
||||||
|
} {
|
||||||
|
const build = asRecord(version?.build_summary);
|
||||||
|
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
||||||
|
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||||
|
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
||||||
|
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
buildToken,
|
||||||
|
inspectionComplete: review.inspection_complete === true,
|
||||||
|
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||||
return String(row.entry_id ?? row.entry_index ?? index);
|
return String(row.entry_id ?? row.entry_index ?? index);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ 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, { type DataGridColumn } from "../../../components/table/DataGrid";
|
import DataGrid, { 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";
|
||||||
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
|
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
|
||||||
|
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
|
||||||
|
|
||||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||||
|
|
||||||
@@ -146,11 +147,22 @@ export function AttachmentRulesDataGrid({
|
|||||||
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addRule(afterIndex = rules.length - 1) {
|
||||||
|
const nextRule = createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? "");
|
||||||
|
onChange(insertAfter(rules, afterIndex, nextRule));
|
||||||
|
}
|
||||||
|
|
||||||
function removeRule(index: number) {
|
function removeRule(index: number) {
|
||||||
setFileChooser(null);
|
setFileChooser(null);
|
||||||
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
|
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveRule(index: number, targetIndex: number) {
|
||||||
|
if (disabled || index === targetIndex) return;
|
||||||
|
setFileChooser(null);
|
||||||
|
onChange(moveArrayItem(rules, index, targetIndex));
|
||||||
|
}
|
||||||
|
|
||||||
function openFileChooser(ruleIndex: number) {
|
function openFileChooser(ruleIndex: number) {
|
||||||
if (onOpenFileChooser) {
|
if (onOpenFileChooser) {
|
||||||
onOpenFileChooser(ruleIndex);
|
onOpenFileChooser(ruleIndex);
|
||||||
@@ -182,9 +194,9 @@ export function AttachmentRulesDataGrid({
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={id}
|
id={id}
|
||||||
rows={rules}
|
rows={rules}
|
||||||
columns={attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, 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={emptyText}
|
emptyText={<div className="data-grid-empty-action"><span>{emptyText}</span><Button variant="primary" onClick={() => addRule(-1)} disabled={disabled}>Add file</Button></div>}
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -208,14 +220,17 @@ export function AttachmentRulesDataGrid({
|
|||||||
|
|
||||||
type AttachmentRuleColumnContext = {
|
type AttachmentRuleColumnContext = {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
rules: AttachmentRule[];
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
activeChooserRuleIndex: number | null;
|
activeChooserRuleIndex: number | null;
|
||||||
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
|
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
|
||||||
|
addRule: (afterIndex?: number) => void;
|
||||||
|
moveRule: (index: number, targetIndex: number) => void;
|
||||||
openFileChooser: (ruleIndex: number) => void;
|
openFileChooser: (ruleIndex: number) => void;
|
||||||
removeRule: (index: number) => void;
|
removeRule: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
||||||
{
|
{
|
||||||
@@ -277,10 +292,22 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
|
|||||||
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "",
|
header: "Actions",
|
||||||
width: 145,
|
width: 150,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button>
|
render: (_rule, index) => (
|
||||||
|
<DataGridRowActions
|
||||||
|
disabled={disabled}
|
||||||
|
onAddBelow={() => addRule(index)}
|
||||||
|
onRemove={() => removeRule(index)}
|
||||||
|
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
|
||||||
|
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
|
||||||
|
addLabel="Add attachment below"
|
||||||
|
removeLabel="Remove attachment"
|
||||||
|
moveUpLabel="Move attachment up"
|
||||||
|
moveDownLabel="Move attachment down"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,21 @@ import { useState } from "react";
|
|||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
import ConfirmDialog from "../../../components/ConfirmDialog";
|
||||||
import { forkCampaignVersion, unlockCampaignVersionValidation, type CampaignVersionDetail, type CampaignVersionListItem } from "../../../api/campaigns";
|
import {
|
||||||
import { canUnlockValidationVersion, formatDateTime } from "../utils/campaignView";
|
forkCampaignVersion,
|
||||||
|
lockCampaignVersionPermanently,
|
||||||
|
unlockCampaignVersionUserLock,
|
||||||
|
unlockCampaignVersionValidation,
|
||||||
|
type CampaignVersionDetail,
|
||||||
|
type CampaignVersionListItem,
|
||||||
|
} from "../../../api/campaigns";
|
||||||
|
import {
|
||||||
|
canUnlockValidationVersion,
|
||||||
|
formatDateTime,
|
||||||
|
isFinalLockedVersion,
|
||||||
|
isPermanentUserLockedVersion,
|
||||||
|
isTemporaryUserLockedVersion,
|
||||||
|
} from "../utils/campaignView";
|
||||||
|
|
||||||
type LockedVersionNoticeProps = {
|
type LockedVersionNoticeProps = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
@@ -13,24 +26,41 @@ type LockedVersionNoticeProps = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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, 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 [unlockConfirmOpen, setUnlockConfirmOpen] = useState(false);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
|
||||||
|
|
||||||
const canUnlock = canUnlockValidationVersion(version);
|
const validationLock = canUnlockValidationVersion(version);
|
||||||
const noticeText = message ?? (canUnlock ? "Validated version. Unlock to edit, or create a copy." : "Final snapshot. Create a copy to edit.");
|
const temporaryUserLock = isTemporaryUserLockedVersion(version);
|
||||||
const lockInfo = getCompactLockInfo(version, canUnlock);
|
const permanentUserLock = isPermanentUserLockedVersion(version);
|
||||||
|
const finalLock = isFinalLockedVersion(version);
|
||||||
|
const presentation = lockPresentation(version, {
|
||||||
|
validationLock,
|
||||||
|
temporaryUserLock,
|
||||||
|
permanentUserLock,
|
||||||
|
finalLock,
|
||||||
|
});
|
||||||
|
|
||||||
async function unlockValidation() {
|
async function runAction(action: Exclude<ConfirmAction, null>) {
|
||||||
if (!version || busy) return;
|
if (!version || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
setLocalMessage("");
|
setLocalMessage("");
|
||||||
try {
|
try {
|
||||||
await unlockCampaignVersionValidation(settings, campaignId, version.id);
|
if (action === "unlock-validation") {
|
||||||
setLocalMessage("Validation invalidated; version is editable again.");
|
await unlockCampaignVersionValidation(settings, campaignId, version.id);
|
||||||
|
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
|
||||||
|
} else if (action === "unlock-user") {
|
||||||
|
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
|
||||||
|
setLocalMessage("Temporary user lock removed. The version is editable again.");
|
||||||
|
} else {
|
||||||
|
await lockCampaignVersionPermanently(settings, campaignId, version.id);
|
||||||
|
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
|
||||||
|
}
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLocalError(err instanceof Error ? err.message : String(err));
|
setLocalError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -47,7 +77,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
|
|||||||
try {
|
try {
|
||||||
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
||||||
current_flow: "manual",
|
current_flow: "manual",
|
||||||
current_step: version.current_step ?? null
|
current_step: version.current_step ?? null,
|
||||||
});
|
});
|
||||||
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -59,43 +89,130 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alert info locked-version-notice">
|
<div className={`alert info locked-version-notice lock-kind-${presentation.kind}`}>
|
||||||
<div className="locked-version-copy">
|
<div className="locked-version-copy">
|
||||||
<strong>Locked version.</strong>{" "}
|
<strong>{presentation.title}</strong>{" "}
|
||||||
<span>{noticeText}</span>
|
<span>{presentation.description}</span>
|
||||||
{lockInfo && <span className="locked-version-reason"> {lockInfo}</span>}
|
{message && <span className="locked-version-context"> {message}</span>}
|
||||||
|
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
|
||||||
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
|
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
|
||||||
{localError && <span className="locked-version-error"> {localError}</span>}
|
{localError && <span className="locked-version-error"> {localError}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions locked-version-actions">
|
<div className="button-row compact-actions locked-version-actions">
|
||||||
{canUnlock && (
|
{validationLock && (
|
||||||
<Button onClick={() => setUnlockConfirmOpen(true)} disabled={!version || busy}>
|
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
|
||||||
{busy ? "Working…" : "Unlock validation"}
|
{busy ? "Working…" : "Unlock validation"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{temporaryUserLock && (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
|
||||||
|
{busy ? "Working…" : "Unlock"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
|
||||||
|
Lock permanently
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||||
{busy ? "Creating copy…" : "Create editable copy"}
|
{busy ? "Creating copy…" : "Create editable copy"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={unlockConfirmOpen}
|
open={confirmAction !== null}
|
||||||
title="Unlock validation?"
|
title={confirmDialogTitle(confirmAction)}
|
||||||
message="This unlocks the version for editing and clears validation/build results and generated jobs for this version."
|
message={confirmDialogMessage(confirmAction)}
|
||||||
confirmLabel="Unlock validation"
|
confirmLabel={confirmDialogLabel(confirmAction)}
|
||||||
tone="danger"
|
tone={confirmAction === "unlock-user" ? "default" : "danger"}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onCancel={() => setUnlockConfirmOpen(false)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setUnlockConfirmOpen(false);
|
const action = confirmAction;
|
||||||
void unlockValidation();
|
setConfirmAction(null);
|
||||||
|
if (action) void runAction(action);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCompactLockInfo(version: CampaignVersionDetail | CampaignVersionListItem | null, canUnlock: boolean): string {
|
type LockFlags = {
|
||||||
if (!version?.locked_at) return "";
|
validationLock: boolean;
|
||||||
const label = canUnlock ? "Validated" : "Locked";
|
temporaryUserLock: boolean;
|
||||||
return `${label} ${formatDateTime(version.locked_at)}.`;
|
permanentUserLock: boolean;
|
||||||
|
finalLock: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function lockPresentation(
|
||||||
|
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||||
|
flags: LockFlags,
|
||||||
|
): { kind: string; title: string; description: string; info: string } {
|
||||||
|
if (flags.temporaryUserLock) {
|
||||||
|
return {
|
||||||
|
kind: "temporary-user",
|
||||||
|
title: "Temporarily locked version.",
|
||||||
|
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
|
||||||
|
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (flags.permanentUserLock) {
|
||||||
|
return {
|
||||||
|
kind: "permanent-user",
|
||||||
|
title: "Permanently locked version.",
|
||||||
|
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
|
||||||
|
info: version?.user_locked_at || version?.published_at
|
||||||
|
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (flags.validationLock) {
|
||||||
|
return {
|
||||||
|
kind: "validation",
|
||||||
|
title: "Temporarily locked after validation.",
|
||||||
|
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
|
||||||
|
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (flags.finalLock) {
|
||||||
|
return {
|
||||||
|
kind: "delivery",
|
||||||
|
title: "Permanently locked delivery snapshot.",
|
||||||
|
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
|
||||||
|
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "other",
|
||||||
|
title: "Locked version.",
|
||||||
|
description: "This version is read-only. Create an editable copy to continue working.",
|
||||||
|
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDialogTitle(action: ConfirmAction): string {
|
||||||
|
if (action === "unlock-validation") return "Unlock validation?";
|
||||||
|
if (action === "unlock-user") return "Unlock temporary lock?";
|
||||||
|
if (action === "permanent") return "Lock permanently?";
|
||||||
|
return "Confirm action";
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDialogMessage(action: ConfirmAction): string {
|
||||||
|
if (action === "unlock-validation") {
|
||||||
|
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
|
||||||
|
}
|
||||||
|
if (action === "unlock-user") {
|
||||||
|
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
|
||||||
|
}
|
||||||
|
if (action === "permanent") {
|
||||||
|
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
|
||||||
|
}
|
||||||
|
return "Continue?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDialogLabel(action: ConfirmAction): string {
|
||||||
|
if (action === "unlock-validation") return "Unlock validation";
|
||||||
|
if (action === "unlock-user") return "Unlock";
|
||||||
|
if (action === "permanent") return "Lock permanently";
|
||||||
|
return "Confirm";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,27 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
|||||||
return asArray(getCampaignJson(version).fields);
|
return asArray(getCampaignJson(version).fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function userLockState(
|
||||||
|
version: CampaignVersionDetail | CampaignVersionListItem | null
|
||||||
|
): "temporary" | "permanent" | null {
|
||||||
|
if (!version) return null;
|
||||||
|
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
|
||||||
|
return version.user_lock_state;
|
||||||
|
}
|
||||||
|
// Backwards compatibility for snapshots created before explicit user locks.
|
||||||
|
return version.published_at ? "permanent" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTemporaryUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||||
|
return userLockState(version) === "temporary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPermanentUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||||
|
return userLockState(version) === "permanent";
|
||||||
|
}
|
||||||
|
|
||||||
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||||
return Boolean(version?.published_at);
|
return userLockState(version) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||||
@@ -86,16 +105,23 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
|
|||||||
|
|
||||||
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||||
if (!version) return false;
|
if (!version) return false;
|
||||||
if (version.locked_at || version.published_at) 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): string {
|
||||||
if (!version) return "No campaign version is loaded.";
|
if (!version) return "No campaign version is loaded.";
|
||||||
if (canUnlockValidationVersion(version)) return `Validation locked at ${formatDateTime(version.locked_at)}. Unlocking will invalidate validation and build state.`;
|
if (isTemporaryUserLockedVersion(version)) {
|
||||||
if (isUserLockedVersion(version)) return `User locked at ${formatDateTime(version.published_at)}. Audit-safe; copy only.`;
|
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
||||||
if (isFinalLockedVersion(version)) return `Final state: ${humanize(version.workflow_state ?? "locked")}.`;
|
}
|
||||||
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`;
|
if (isPermanentUserLockedVersion(version)) {
|
||||||
|
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
|
||||||
|
}
|
||||||
|
if (canUnlockValidationVersion(version)) {
|
||||||
|
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
|
||||||
|
}
|
||||||
|
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
|
||||||
|
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
|
||||||
return "Editable working version.";
|
return "Editable working version.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1745,7 +1745,7 @@
|
|||||||
width: 2px;
|
width: 2px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
margin: 4px 0;
|
margin: 4px 0 -14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(to bottom, var(--review-stage-color), var(--review-next-stage-color));
|
background: linear-gradient(to bottom, var(--review-stage-color), var(--review-next-stage-color));
|
||||||
opacity: .82;
|
opacity: .82;
|
||||||
@@ -2013,3 +2013,51 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible workflow stage headers keep status and disclosure controls grouped. */
|
||||||
|
.review-flow-stage-header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
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 {
|
||||||
|
min-height: 58px;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-version-context {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.review-flow-stage-header-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1058,3 +1058,191 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* List-valued DataGrid columns and checkbox filters. */
|
||||||
|
.data-grid-list-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 28px 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-actions button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--blue);
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-actions button:hover,
|
||||||
|
.data-grid-list-filter-actions button:focus-visible {
|
||||||
|
color: var(--text-strong);
|
||||||
|
text-decoration: underline;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-options {
|
||||||
|
max-height: 230px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-row > label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-filter-row input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-option-remove,
|
||||||
|
.data-grid-list-option-add button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-option-remove:hover,
|
||||||
|
.data-grid-list-option-remove:focus-visible,
|
||||||
|
.data-grid-list-option-add button:hover,
|
||||||
|
.data-grid-list-option-add button:focus-visible {
|
||||||
|
color: var(--text-strong);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-option-add {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-list-option-add input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consistent row-level collection actions for editable DataGrid tables. */
|
||||||
|
.data-grid-row-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 28px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-row-action {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-row-action:hover:not(:disabled),
|
||||||
|
.data-grid-row-action:focus-visible:not(:disabled) {
|
||||||
|
border-color: var(--line-dark);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-row-action.is-add {
|
||||||
|
border-color: color-mix(in srgb, var(--blue) 52%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--blue) 10%, #fff);
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-row-action.is-remove {
|
||||||
|
border-color: rgba(171, 70, 61, .35);
|
||||||
|
background: rgba(171, 70, 61, .06);
|
||||||
|
color: #9f433b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-row-action:disabled {
|
||||||
|
cursor: default;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
14
src/utils/arrayOrder.ts
Normal file
14
src/utils/arrayOrder.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function insertAfter<T>(items: T[], index: number, item: T): T[] {
|
||||||
|
const insertionIndex = Math.max(0, Math.min(items.length, index + 1));
|
||||||
|
return [...items.slice(0, insertionIndex), item, ...items.slice(insertionIndex)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
|
||||||
|
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length || fromIndex === toIndex) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const next = [...items];
|
||||||
|
const [item] = next.splice(fromIndex, 1);
|
||||||
|
next.splice(toIndex, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user