diff --git a/src/components/DismissibleAlert.tsx b/src/components/DismissibleAlert.tsx index 679d9e5..f77cf57 100644 --- a/src/components/DismissibleAlert.tsx +++ b/src/components/DismissibleAlert.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { X } from "lucide-react"; type AlertTone = "success" | "info" | "warning" | "danger"; @@ -13,6 +14,26 @@ type DismissibleAlertProps = { resetKey?: string | number; }; +let floatingAlertRoot: HTMLElement | null = null; + +function getFloatingAlertRoot(): HTMLElement | null { + if (typeof document === "undefined") return null; + if (floatingAlertRoot?.isConnected) return floatingAlertRoot; + + const existing = document.getElementById("app-floating-alerts"); + if (existing) { + floatingAlertRoot = existing; + return floatingAlertRoot; + } + + floatingAlertRoot = document.createElement("div"); + floatingAlertRoot.id = "app-floating-alerts"; + floatingAlertRoot.className = "alert-floating-stack"; + floatingAlertRoot.setAttribute("aria-label", "Application notices"); + document.body.appendChild(floatingAlertRoot); + return floatingAlertRoot; +} + export default function DismissibleAlert({ tone = "info", children, @@ -31,8 +52,7 @@ export default function DismissibleAlert({ if (!visible) return null; const role = tone === "danger" || tone === "warning" ? "alert" : "status"; - - return ( + const alert = (
); + + if (!floating) return alert; + const root = getFloatingAlertRoot(); + return root ? createPortal(alert, root) : alert; } diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx index 95509cc..9ee149c 100644 --- a/src/components/table/DataGrid.tsx +++ b/src/components/table/DataGrid.tsx @@ -31,7 +31,7 @@ type DataGridState = { sort?: { columnId: string; direction: DataGridSortDirection }; filters?: Record; widths?: Record; - fillColumnId?: string; + fillColumnId?: string | null; }; type DataGridProps = { @@ -62,7 +62,7 @@ export default function DataGrid({ columns, getRowKey, emptyText = "No rows found.", - fit = "content", + fit = "container", className = "", rowClassName, storageKey @@ -74,8 +74,9 @@ export default function DataGrid({ startX: number; startWidth: number; baseWidths: Record; - fillColumnId?: string; - fillStartWidth?: number; + immediateRightColumnId?: string; + immediateRightStartWidth?: number; + shrinkRoomWithoutScroll: number; } | null>(null); const [openFilterColumnId, setOpenFilterColumnId] = useState(null); const [filterPosition, setFilterPosition] = useState(null); @@ -85,6 +86,13 @@ export default function DataGrid({ const filterPopoverRef = useRef(null); const [measuredWidths, setMeasuredWidths] = useState>({}); + useEffect(() => { + // Repair persisted state from older resize implementations. Fixed columns + // never own user-resizable widths, and only a resizable, non-sticky column + // may be retained as the layout filler. + setState((current) => sanitizePersistedColumnState(columns, current)); + }, [columns]); + useEffect(() => { try { window.localStorage.setItem(localStorageKey, JSON.stringify(state)); @@ -141,40 +149,58 @@ export default function DataGrid({ useEffect(() => { if (!resizeState) return; const activeResize = resizeState; + function onMove(event: MouseEvent) { const column = columns.find((item) => item.id === activeResize.columnId); - const minWidth = column?.minWidth ?? 80; - const maxWidth = column?.maxWidth ?? 2000; - const rawDelta = event.clientX - activeResize.startX; - const fillColumn = activeResize.fillColumnId - ? columns.find((item) => item.id === activeResize.fillColumnId) - : undefined; - const nextWidths = { ...activeResize.baseWidths }; + if (!column) return; - if (fillColumn && fillColumn.id !== activeResize.columnId) { - const fillMinWidth = fillColumn.minWidth ?? 80; - const fillMaxWidth = fillColumn.maxWidth ?? 2000; - const fillStartWidth = activeResize.fillStartWidth - ?? activeResize.baseWidths[fillColumn.id] - ?? columnPixelWidth(fillColumn, activeResize.baseWidths[fillColumn.id], measuredWidths[fillColumn.id]); - const minDelta = Math.max(minWidth - activeResize.startWidth, fillStartWidth - fillMaxWidth); - const maxDelta = Math.min(maxWidth - activeResize.startWidth, fillStartWidth - fillMinWidth); - const boundedDelta = Math.min(maxDelta, Math.max(minDelta, rawDelta)); - nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta; - nextWidths[fillColumn.id] = fillStartWidth - boundedDelta; + const minWidth = column.minWidth ?? 80; + const maxWidth = column.maxWidth ?? 2000; + const rawDelta = event.clientX - activeResize.startX; + const activeDelta = Math.min( + maxWidth - activeResize.startWidth, + Math.max(minWidth - activeResize.startWidth, rawDelta) + ); + const nextWidths = { ...activeResize.baseWidths }; + let fillColumnId: string | null | undefined; + + if (activeDelta > 0 && activeResize.immediateRightColumnId) { + // A growing column may only shrink its immediate neighbour. Once that + // neighbour reaches its minimum, the grid grows and can scroll rather + // than pulling space from a more distant column. + const neighbour = columns.find((item) => item.id === activeResize.immediateRightColumnId); + const neighbourStartWidth = activeResize.immediateRightStartWidth ?? 0; + const neighbourMinWidth = neighbour?.minWidth ?? 80; + const absorbedDelta = Math.min(activeDelta, Math.max(0, neighbourStartWidth - neighbourMinWidth)); + nextWidths[activeResize.columnId] = activeResize.startWidth + activeDelta; + nextWidths[activeResize.immediateRightColumnId] = neighbourStartWidth - absorbedDelta; + fillColumnId = activeResize.immediateRightColumnId; } else { - nextWidths[activeResize.columnId] = Math.min(maxWidth, Math.max(minWidth, activeResize.startWidth + rawDelta)); + // Shrinking never resizes another column. It consumes only horizontal + // overflow that is still available to the right of the viewport. Once + // that room is exhausted, resizing stops instead of growing a buffer + // column, reclaiming width from the left, or shifting the scroll view. + const boundedDelta = activeDelta < 0 + ? Math.max(activeDelta, -activeResize.shrinkRoomWithoutScroll) + : activeDelta; + nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta; + // Exact pixel tracks remain fixed during the drag, so no implicit + // flexible filler can change a different column behind the pointer. + fillColumnId = null; } setState((current) => ({ ...current, widths: nextWidths, - fillColumnId: activeResize.fillColumnId + fillColumnId })); } + function onUp() { + setState((current) => sanitizePersistedColumnState(columns, current)); setResizeState(null); } + window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); return () => { @@ -246,7 +272,16 @@ export default function DataGrid({ }); }, [rows, columns, state.filters, state.sort, filterTypes]); - const stretchedColumnIds = useMemo(() => chooseStretchedColumns(columns, state.widths, state.fillColumnId), [columns, state.widths, state.fillColumnId]); + const stretchedColumnIds = useMemo( + () => chooseStretchedColumns( + columns, + state.widths, + state.fillColumnId, + fit, + Boolean(resizeState && !state.fillColumnId) + ), + [columns, state.widths, state.fillColumnId, fit, resizeState] + ); const templateColumns = columns.map((column) => widthForColumn(column, state.widths?.[column.id], stretchedColumnIds.has(column.id))).join(" "); const hasFlexibleColumns = columns.some((column) => stretchedColumnIds.has(column.id) || isFlexibleColumn(column, state.widths?.[column.id])); const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]); @@ -335,19 +370,29 @@ export default function DataGrid({ event.stopPropagation(); const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths); const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]); - const fillColumnId = chooseResizeFillColumn(columns, column.id); - const fillColumn = fillColumnId ? columns.find((item) => item.id === fillColumnId) : undefined; - const fillStartWidth = fillColumn - ? baseWidths[fillColumn.id] ?? columnPixelWidth(fillColumn, state.widths?.[fillColumn.id], measuredWidths[fillColumn.id]) + const immediateRightColumn = chooseImmediateResizePartner(columns, column.id); + const immediateRightStartWidth = immediateRightColumn + ? baseWidths[immediateRightColumn.id] ?? columnPixelWidth(immediateRightColumn, state.widths?.[immediateRightColumn.id], measuredWidths[immediateRightColumn.id]) : undefined; - setState((current) => ({ ...current, widths: { ...baseWidths }, fillColumnId })); + const gridElement = gridRef.current; + const shrinkRoomWithoutScroll = gridElement + ? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth - gridElement.scrollLeft) + : 0; + setState((current) => ({ + ...current, + widths: { ...baseWidths }, + // Exact measured tracks remain stable during the drag. + // No flexible column is allowed to become an implicit partner. + fillColumnId: undefined + })); setResizeState({ columnId: column.id, startX: event.clientX, startWidth: currentWidth, baseWidths, - fillColumnId, - fillStartWidth + immediateRightColumnId: immediateRightColumn?.id, + immediateRightStartWidth, + shrinkRoomWithoutScroll }); }} > @@ -486,6 +531,25 @@ function loadState(key: string): DataGridState { } } +function sanitizePersistedColumnState(columns: DataGridColumn[], state: DataGridState): DataGridState { + const resizableColumnIds = new Set(columns.filter((column) => column.resizable).map((column) => column.id)); + const nextWidths = Object.fromEntries( + Object.entries(state.widths ?? {}).filter(([columnId]) => resizableColumnIds.has(columnId)) + ); + const fillColumn = state.fillColumnId + ? columns.find((column) => column.id === state.fillColumnId) + : undefined; + const nextFillColumnId = fillColumn?.resizable && !fillColumn.sticky ? fillColumn.id : undefined; + const widthsChanged = Object.keys(nextWidths).length !== Object.keys(state.widths ?? {}).length; + const fillChanged = nextFillColumnId !== state.fillColumnId; + if (!widthsChanged && !fillChanged) return state; + return { + ...state, + widths: Object.keys(nextWidths).length > 0 ? nextWidths : undefined, + fillColumnId: nextFillColumnId + }; +} + function widthForColumn(column: DataGridColumn, savedWidth?: number, stretch = false): string { if (stretch) { const baseWidth = savedWidth ?? fixedWidthFloor(column); @@ -497,59 +561,54 @@ function widthForColumn(column: DataGridColumn, savedWidth?: number, stret return `minmax(${column.minWidth ?? 140}px, 1fr)`; } -function chooseStretchedColumns(columns: DataGridColumn[], savedWidths?: Record, fillColumnId?: string): Set { - if (columns.length === 0) return new Set(); +function chooseStretchedColumns( + columns: DataGridColumn[], + savedWidths?: Record, + fillColumnId?: string | null, + fit: "content" | "container" = "container", + suppressFallbackFill = false +): Set { + if (fit === "content" || columns.length === 0 || fillColumnId === null) return new Set(); const savedColumnIds = new Set(Object.keys(savedWidths ?? {})); + if (suppressFallbackFill && savedColumnIds.size > 0 && !fillColumnId) return new Set(); if (savedColumnIds.size > 0) { - const preferredFillColumnId = chooseResizeFillColumn(columns); - if (fillColumnId && fillColumnId === preferredFillColumnId && columns.some((column) => column.id === fillColumnId)) { - return new Set([fillColumnId]); - } + const requestedFill = columns.find((column) => column.id === fillColumnId && !column.sticky); + if (requestedFill) return new Set([requestedFill.id]); const unsizedNonStickyResizable = columns.filter((column) => column.resizable && !column.sticky && !savedColumnIds.has(column.id)); if (unsizedNonStickyResizable.length > 0) return new Set(unsizedNonStickyResizable.map((column) => column.id)); - const unsizedFlexible = columns.filter((column) => !savedColumnIds.has(column.id) && isFlexibleColumn(column, savedWidths?.[column.id])); + const unsizedFlexible = columns.filter((column) => !column.sticky && !savedColumnIds.has(column.id) && isFlexibleColumn(column, savedWidths?.[column.id])); if (unsizedFlexible.length > 0) return new Set(unsizedFlexible.map((column) => column.id)); - const fallback = chooseResizeFillColumn(columns); + const fallback = chooseLayoutFillColumn(columns); return new Set(fallback ? [fallback] : []); } const nonStickyResizable = columns.filter((column) => column.resizable && !column.sticky); if (nonStickyResizable.length > 0) return new Set(nonStickyResizable.map((column) => column.id)); - const flexible = columns.filter((column) => isFlexibleColumn(column, savedWidths?.[column.id])); - if (flexible.length > 0) return new Set(); + const flexible = columns.filter((column) => !column.sticky && isFlexibleColumn(column, savedWidths?.[column.id])); + if (flexible.length > 0) return new Set(flexible.map((column) => column.id)); - const fallback = chooseResizeFillColumn(columns); + // Fixed-width tables still fill their container by default. Prefer the last + // ordinary data column so sticky action columns remain visually stable. + const fallback = chooseLayoutFillColumn(columns); return new Set(fallback ? [fallback] : []); } -function chooseResizeFillColumn(columns: DataGridColumn[], activeColumnId?: string): string | undefined { - const activeIndex = activeColumnId ? columns.findIndex((column) => column.id === activeColumnId) : -1; - const candidateColumns = activeIndex >= 0 ? columns.slice(activeIndex + 1) : columns; +function chooseLayoutFillColumn(columns: DataGridColumn[]): string | undefined { + const nonSticky = columns.filter((column) => !column.sticky); + const resizable = nonSticky.filter((column) => column.resizable); + return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id; +} - // Only resizable, non-sticky columns are allowed to absorb spare width. This keeps - // fixed/action columns fixed. During active resize the absorber must be to the right - // of the dragged column, so columns on the left never move. If there is no eligible - // column to the right, the dragged column itself may stay stretched; otherwise no - // absorber is selected and the grid may grow/shrink naturally. - const rightmostResizableNonSticky = (items: DataGridColumn[]) => { - const candidates = items.filter((column) => column.resizable && !column.sticky); - return candidates[candidates.length - 1]; - }; - - const rightFillColumn = rightmostResizableNonSticky(candidateColumns); - if (rightFillColumn) return rightFillColumn.id; - - if (activeIndex >= 0) { - const activeColumn = columns[activeIndex]; - if (activeColumn?.resizable && !activeColumn.sticky) return activeColumn.id; - } - - return undefined; +function chooseImmediateResizePartner(columns: DataGridColumn[], activeColumnId: string): DataGridColumn | undefined { + const activeIndex = columns.findIndex((column) => column.id === activeColumnId); + if (activeIndex < 0) return undefined; + const neighbour = columns[activeIndex + 1]; + return neighbour?.resizable && !neighbour.sticky ? neighbour : undefined; } function measuredColumnWidths( diff --git a/src/features/auth/LoginModal.tsx b/src/features/auth/LoginModal.tsx index c367ddf..e6ad8ce 100644 --- a/src/features/auth/LoginModal.tsx +++ b/src/features/auth/LoginModal.tsx @@ -3,6 +3,7 @@ import type { ApiSettings, LoginResponse } from "../../types"; import { login } from "../../api/auth"; import Button from "../../components/Button"; import FormField from "../../components/FormField"; +import DismissibleAlert from "../../components/DismissibleAlert"; export default function LoginModal({ settings, @@ -42,7 +43,7 @@ export default function LoginModal({
Development default: user admin@example.local, password dev-admin.
- {error &&
{error}
} + {error && {error}} setEmail(e.target.value)} /> diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index b4c9a50..b1ef58e 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -86,8 +86,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
- {error && {error}} - {localError && {localError}} + {error && {error}} + {localError && {localError}} {locked && } diff --git a/src/features/campaigns/CampaignAuditPage.tsx b/src/features/campaigns/CampaignAuditPage.tsx index 62cca0a..e18cf33 100644 --- a/src/features/campaigns/CampaignAuditPage.tsx +++ b/src/features/campaigns/CampaignAuditPage.tsx @@ -23,7 +23,7 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings: - {error && {error}} + {error && {error}} diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx index a2f80c3..7e3954d 100644 --- a/src/features/campaigns/CampaignFieldsPage.tsx +++ b/src/features/campaigns/CampaignFieldsPage.tsx @@ -156,9 +156,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: - {error && {error}} - {localError && {localError}} - {fieldNameWarning && {fieldNameWarning}} + {error && {error}} + {localError && {localError}} + {fieldNameWarning && {fieldNameWarning}} {locked && } diff --git a/src/features/campaigns/CampaignJsonView.tsx b/src/features/campaigns/CampaignJsonView.tsx index a9c1891..a98bf72 100644 --- a/src/features/campaigns/CampaignJsonView.tsx +++ b/src/features/campaigns/CampaignJsonView.tsx @@ -28,7 +28,7 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A - {error && {error}} + {error && {error}} {!loading || version ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
} diff --git a/src/features/campaigns/CampaignListPage.tsx b/src/features/campaigns/CampaignListPage.tsx index 0effb18..e4c1195 100644 --- a/src/features/campaigns/CampaignListPage.tsx +++ b/src/features/campaigns/CampaignListPage.tsx @@ -115,7 +115,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings } Sign in with your user account or configure an automation API key under Settings to load campaigns. )} - {error && {error}} + {error && {error}}
diff --git a/src/features/campaigns/CampaignOverviewPage.tsx b/src/features/campaigns/CampaignOverviewPage.tsx index b182e98..3abd381 100644 --- a/src/features/campaigns/CampaignOverviewPage.tsx +++ b/src/features/campaigns/CampaignOverviewPage.tsx @@ -96,8 +96,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
- {error && {error}} - {message && {message}} + {error && {error}} + {message && {message}} diff --git a/src/features/campaigns/CampaignReportPage.tsx b/src/features/campaigns/CampaignReportPage.tsx index 4509393..dfdf36b 100644 --- a/src/features/campaigns/CampaignReportPage.tsx +++ b/src/features/campaigns/CampaignReportPage.tsx @@ -24,7 +24,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings: - {error && {error}} + {error && {error}}
diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index af7d832..84835ec 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -68,8 +68,8 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
- {error && {error}} - {localError && {localError}} + {error && {error}} + {localError && {localError}} {locked && } diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx index ecb4f00..294cea3 100644 --- a/src/features/campaigns/MailSettingsPage.tsx +++ b/src/features/campaigns/MailSettingsPage.tsx @@ -261,8 +261,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A - {error && {error}} - {localError && {localError}} + {error && {error}} + {localError && {localError}} {locked && } @@ -331,7 +331,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A - {mockError && {mockError}} + {mockError && {mockError}} + {result.message} {result.ok && typeof authenticated === "boolean" && ( Authentication: {authenticated ? "credentials accepted" : "not used"}. )} - + ); } function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) { if (!result) return null; if (!result.ok) { - return
{result.message}
; + return {result.message}; } return ( -
+

{result.message}

Detected Sent folder: {result.detected_sent_folder || "—"}

{result.detected_sent_folder && } @@ -439,6 +439,6 @@ function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailI {result.folders.length > 12 && +{result.folders.length - 12} more}
)} - + ); } \ No newline at end of file diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx index 961bf75..6572df1 100644 --- a/src/features/campaigns/RecipientDataPage.tsx +++ b/src/features/campaigns/RecipientDataPage.tsx @@ -136,8 +136,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: - {error && {error}} - {localError && {localError}} + {error && {error}} + {localError && {localError}} {locked && } diff --git a/src/features/campaigns/RecipientDetailsPage.tsx b/src/features/campaigns/RecipientDetailsPage.tsx index 17657af..8204948 100644 --- a/src/features/campaigns/RecipientDetailsPage.tsx +++ b/src/features/campaigns/RecipientDetailsPage.tsx @@ -81,8 +81,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting - {error && {error}} - {localError && {localError}} + {error && {error}} + {localError && {localError}} {locked && } diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index f2422b3..7b747bb 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -148,9 +148,9 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe - {error && {error}} - {sendMessage && {sendMessage}} - {mockMessage && {mockMessage}} + {error && {error}} + {sendMessage && {sendMessage}} + {mockMessage && {mockMessage}} {locked && } diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index 0daab5a..e77b2f6 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -142,8 +142,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A - {error && {error}} - {localError && {localError}} + {error && {error}} + {localError && {localError}} {locked && } diff --git a/src/features/campaigns/components/ReviewWorkflowCards.tsx b/src/features/campaigns/components/ReviewWorkflowCards.tsx index 50528d0..1341561 100644 --- a/src/features/campaigns/components/ReviewWorkflowCards.tsx +++ b/src/features/campaigns/components/ReviewWorkflowCards.tsx @@ -107,7 +107,7 @@ export default function ReviewWorkflowCards({ return ( <> - {actionMessage && {actionMessage}} + {actionMessage && {actionMessage}} void; @@ -146,7 +147,7 @@ export function CampaignUnsavedChangesProvider({ children }: { children: ReactNo

{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}

- {saveError &&
{saveError}
} + {saveError && {saveError}}