feat: complete shared platform UI contracts

This commit is contained in:
2026-07-31 04:21:34 +02:00
parent 5b55f59a92
commit 7b8072d049
19 changed files with 764 additions and 285 deletions
+3
View File
@@ -16,6 +16,9 @@ Core owns:
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
The shared DataGrid sizing and resize invariants are specified in
[`docs/DATAGRID_SIZING_CONTRACT.md`](docs/DATAGRID_SIZING_CONTRACT.md).
Platform and feature modules own their backend routers, models, migrations,
permissions, frontend packages, nav items, and route contributions. Access,
tenancy, policy, audit, and admin behavior live in their owning platform
+63
View File
@@ -0,0 +1,63 @@
# DataGrid Sizing Contract
`DataGrid` turns every declared track into a deterministic pixel layout after
its container has a measurable width. The same contract is used on initial
layout, container resize, persisted-layout restore, and pointer resize.
## Column Declarations
- `width: number` or `Npx` is the preferred pixel width.
- `width: N%` is a preferred share of the measured container.
- `width: Nfr` shares residual width by fraction weight.
- `width: minmax(Npx, preferred)` combines a hard lower bound with any
supported preferred width.
- An omitted width and the legacy `fill` flag are one-fraction flexible tracks.
- `minWidth` is a hard floor. Header sort, filter, and resize controls may raise
the effective accessible floor.
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
cover layout it is a preferred maximum: passive tracks may exceed it when
that is necessary to keep the table flush with its container.
## Layout Modes
| `initialFit` | `resizeBehavior` | Initial layout | Pointer resize |
| --- | --- | --- | --- |
| `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | The active change is compensated by ordinary peer columns. Passive maxima are soft; no blank filler column is created. |
| `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. |
| `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. |
| `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. |
| `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. |
Sticky columns do not absorb ordinary cover residuals and are not resize
compensation targets. A last resizable column compensates against columns to
its left, so it follows the same right-edge invariant as earlier columns.
## Persistence
Only the pixel layout resulting from an explicit user resize is persisted.
Persisted widths are keyed by a signature containing column IDs, declared
widths and bounds, resize affordances, sticky placement, initial fit, and resize
behavior. A changed signature discards the old override and recomputes the
declared layout.
Container reconciliation is suspended while a pointer drag is active. On
release, the already-rendered pixel layout becomes the persisted preference;
there is no second fit pass and therefore no drag-end snap.
## Regression Matrix
`webui/tests/data-grid-sizing.test.ts` covers:
- pixel, percentage, fraction, `minmax`, omitted, and legacy-fill tracks;
- preferred max exhaustion without a synthetic filler column;
- hard-minimum horizontal overflow;
- fixed-only cover grids;
- persisted overrides under growth and viewport pressure;
- stale layout signatures;
- first, middle, and last-column peer compensation;
- free, cover, and constrained resizing;
- cover-expanded tracks that already exceed preferred maxima; and
- preservation of the pointer layout across the commit fit.
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
shape and guards against reintroducing a synthetic buffer cell.
+16
View File
@@ -222,11 +222,13 @@ DocumentationSourceKind = Literal[
"route",
"capability",
"policy",
"release_catalog",
"configuration_package",
"wiki",
"repository",
]
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
CapabilityStability = Literal["experimental", "stable", "deprecated"]
@dataclass(frozen=True, slots=True)
@@ -336,12 +338,25 @@ class DocumentationSourceDefinition:
documentation_types: tuple[DocumentationType, ...] = ("admin",)
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
state: DocumentationSourceState = "configured"
state_reason: str | None = None
provenance: Mapping[str, Any] = field(default_factory=dict)
inspection: Mapping[str, Any] = field(default_factory=dict)
link: DocumentationLink | None = None
configuration_key: str | None = None
@dataclass(frozen=True, slots=True)
class CapabilityDocumentation:
"""Provider-owned capability metadata safe for generic platform consumers."""
label: str
summary: str
contract_version: str | None = None
stability: CapabilityStability = "stable"
documentation_types: tuple[DocumentationType, ...] = ("admin",)
audience: tuple[str, ...] = ()
class ResourceAclProvider(Protocol):
resource_type: str
@@ -412,6 +427,7 @@ class ModuleManifest:
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
+22
View File
@@ -614,6 +614,28 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
for capability, metadata in manifest.capability_documentation.items():
if capability not in manifest.capability_factories:
raise RegistryError(
f"Module {manifest.id!r} documents capability {capability!r} "
"but does not provide it"
)
if not metadata.label.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a label"
)
if not metadata.summary.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a summary"
)
if metadata.contract_version is not None and not metadata.contract_version.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation contract version must not be empty"
)
provider_keys: set[str] = set()
for registration in manifest.documentation_configuration_providers:
if not registration.keys:
@@ -3,6 +3,7 @@ from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationConfigurationDecision,
DocumentationConfigurationProviderRegistration,
DocumentationCondition,
@@ -116,6 +117,39 @@ class DocumentationTopicContractTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
duplicate.validate()
def test_capability_documentation_is_typed_and_must_match_a_provider(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
capability_factories={"example.lookup": lambda _context: object()},
capability_documentation={
"example.lookup": CapabilityDocumentation(
label="Example lookup",
summary="Resolves example records without exposing provider internals.",
contract_version="2",
audience=("module_admin",),
),
},
))
registry.validate()
missing_provider = PlatformRegistry()
missing_provider.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
capability_documentation={
"example.lookup": CapabilityDocumentation(
label="Example lookup",
summary="Resolves example records.",
),
},
))
with self.assertRaisesRegex(RegistryError, "does not provide it"):
missing_provider.validate()
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
registry = PlatformRegistry()
+20
View File
@@ -29,6 +29,7 @@
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
"@tiptap/core": "^3.29.2",
@@ -435,6 +436,21 @@
}
}
},
"../../govoplan-tenancy/webui": {
"name": "@govoplan/tenancy-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-views/webui": {
"name": "@govoplan/views-webui",
"version": "0.1.0",
@@ -1305,6 +1321,10 @@
"resolved": "../../govoplan-search/webui",
"link": true
},
"node_modules/@govoplan/tenancy-webui": {
"resolved": "../../govoplan-tenancy/webui",
"link": true
},
"node_modules/@govoplan/views-webui": {
"resolved": "../../govoplan-views/webui",
"link": true
+1
View File
@@ -68,6 +68,7 @@
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
"@tiptap/core": "^3.29.2",
+4 -1
View File
@@ -23,6 +23,7 @@ const packageByModule = {
risk_compliance: "@govoplan/risk-compliance-webui",
scheduling: "@govoplan/scheduling-webui",
search: "@govoplan/search-webui",
tenancy: "@govoplan/tenancy-webui",
views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui"
};
@@ -30,6 +31,8 @@ const packageByModule = {
const cases = [
{ name: "core-only", modules: [] },
{ name: "access-only", modules: ["access"] },
{ name: "tenancy-only", modules: ["tenancy"] },
{ name: "access-with-tenancy", modules: ["access", "tenancy"] },
{ name: "admin-only", modules: ["admin"] },
{ name: "addresses-only", modules: ["addresses"] },
{ name: "access-with-admin", modules: ["access", "admin"] },
@@ -58,7 +61,7 @@ const cases = [
{ name: "search-only", modules: ["search"] },
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
];
const npmExec = process.env.npm_execpath;
+67
View File
@@ -0,0 +1,67 @@
import { Link } from "react-router";
import type { AuthInfo, WizardDirectoryEntry } from "../types";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { hasAnyScope, hasScope } from "../utils/permissions";
import Card from "./Card";
export type WizardDirectoryProps = {
entries: WizardDirectoryEntry[];
auth: AuthInfo;
emptyText?: string;
};
function canOpenWizard(auth: AuthInfo, entry: WizardDirectoryEntry): boolean {
if (entry.available === false) return false;
if (entry.allOf?.some((scope) => !hasScope(auth, scope))) return false;
if (entry.anyOf?.length && !hasAnyScope(auth, entry.anyOf)) return false;
return true;
}
export default function WizardDirectory({
entries,
auth,
emptyText = "No guided workflows are available."
}: WizardDirectoryProps) {
const { translateText } = usePlatformLanguage();
const visibleEntries = entries.filter(
(entry) =>
!entry.anyOf?.length
|| hasAnyScope(auth, entry.anyOf)
|| entry.available === false
);
if (!visibleEntries.length) {
return <p className="muted">{translateText(emptyText)}</p>;
}
return (
<div className="wizard-directory-grid">
{visibleEntries.map((entry) => {
const available = canOpenWizard(auth, entry);
return (
<Card
key={`${entry.moduleId}:${entry.id}`}
title={entry.label}
actions={
available
? (
<Link className="btn btn-primary" to={entry.to}>
{translateText(entry.actionLabel ?? "Open")}
</Link>
)
: null
}
>
{entry.description && <p>{translateText(entry.description)}</p>}
{!available && entry.unavailableReason && (
<p className="muted small-note">
{translateText(entry.unavailableReason)}
</p>
)}
</Card>
);
})}
</div>
);
}
+75 -234
View File
@@ -5,20 +5,17 @@ import StatusBadge from "../StatusBadge";
import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
import {
DATA_GRID_MAX_TRACK_WIDTH as BUFFER_MAX_WIDTH,
DATA_GRID_MAX_TRACK_WIDTH,
dataGridColumnPixelWidth as columnPixelWidth,
dataGridColumnResizeWeight,
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
dataGridLayoutSignature,
dataGridWidthsForLayout,
distributeDataGridResizeAmount as distributeResizeAmount,
effectiveDataGridColumnMaxWidth as effectiveColumnMaxWidth,
effectiveDataGridColumnMinWidth as effectiveColumnMinWidth,
fitDataGridColumns,
isFlexibleDataGridWidth as isFlexibleWidth,
roundDataGridWidths as roundWidthRecord,
totalDataGridColumnWidth as totalColumnWidth,
type DataGridResizeTarget as ResizeTarget
resizeDataGridColumn,
roundDataGridWidths as roundWidthRecord
} from "./dataGridSizing";
export type DataGridSortDirection = "asc" | "desc";
@@ -89,7 +86,10 @@ export type DataGridColumn<T> = {
width?: number | string;
/** Hard lower bound. Header controls may require a larger accessible minimum. */
minWidth?: number;
/** Hard upper bound. Space left after all maxima is assigned to the neutral buffer track. */
/**
* Upper bound for direct resizing. A cover layout may exceed preferred
* maxima only when that is required to consume the complete container.
*/
maxWidth?: number;
resizable?: boolean;
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
@@ -119,8 +119,6 @@ type DataGridState = {
layoutSignature?: string;
/** Legacy layout state; removed when persisted state is sanitized. */
fillColumnId?: string | null;
/** Width of the synthetic, handle-less coverage track. */
bufferWidth?: number;
};
type DataGridBaseProps<T> = {
@@ -136,7 +134,11 @@ type DataGridBaseProps<T> = {
initialFit?: DataGridInitialFit;
/** @deprecated Use initialFit instead. */
fit?: DataGridInitialFit;
/** Defaults to cover. Free is automatically promoted to cover when a right-sticky column exists. */
/**
* Cover keeps the right edge fixed by redistributing every resize across
* real columns. Free permits underflow and horizontal growth. Free is
* automatically promoted to cover when a right-sticky column exists.
*/
resizeBehavior?: DataGridResizeBehavior;
className?: string;
rowClassName?: (row: T, index: number) => string | undefined;
@@ -190,28 +192,14 @@ type FilterPosition = {
type ColumnResizeState = {
columnId: string;
startX: number;
startWidth: number;
minWidth: number;
maxWidth: number;
baseWidths: Record<string, number>;
rightResizeTargets: ResizeTarget[];
bufferTarget?: ResizeTarget;
bufferStartWidth: number;
shrinkRoomWithoutScroll: number;
/**
* The last resizable column may consume overflow on either side of the
* current viewport. As its track shrinks, the browser clamps scrollLeft and
* moves the viewport left, while the grid still covers the complete surface.
*/
lastResizableShrinkRoom: number;
isLastResizableColumn: boolean;
uncompensatedShrinkRoom: number;
behavior: DataGridResizeBehavior;
};
const STORAGE_PREFIX = "govoplan.datagrid.";
const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12;
const BUFFER_COLUMN_ID = "__data_grid_resize_buffer__";
export default function DataGrid<T>({
id,
@@ -241,8 +229,6 @@ export default function DataGrid<T>({
"cover" :
resizeBehavior;
const layoutSignature = dataGridLayoutSignature(columns, resolvedInitialFit, effectiveResizeBehavior);
const bufferInsertIndex = findBufferInsertIndex(columns);
const containerResizeColumns = columns.filter(isContainerResizeColumn);
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
const initialFiltersKey = JSON.stringify(initialFilters);
@@ -368,31 +354,33 @@ export default function DataGrid<T>({
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
if (!mustFit) {
if (signatureMatches && current.widths === undefined && current.bufferWidth === undefined) return current;
if (signatureMatches && current.widths === undefined) return current;
return {
...current,
widths: undefined,
userWidths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: undefined
fillColumnId: undefined
};
}
const layout = fitDataGridColumns(columns, nextContainerWidth, measuredWidths, userWidths);
const nextBufferWidth = normalizeBufferWidth(layout.bufferWidth, containerResizeColumns.length === 0);
const layout = fitDataGridColumns(
columns,
nextContainerWidth,
measuredWidths,
userWidths,
effectiveResizeBehavior
);
if (
signatureMatches
&& shallowEqualNumberRecords(current.widths ?? {}, layout.widths)
&& nextBufferWidth === current.bufferWidth
) return current;
return {
...current,
widths: layout.widths,
userWidths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: nextBufferWidth
fillColumnId: undefined
};
});
});
@@ -421,57 +409,25 @@ export default function DataGrid<T>({
function onMove(event: MouseEvent) {
const rawDelta = event.clientX - activeResize.startX;
const activeDelta = Math.min(
activeResize.maxWidth - activeResize.startWidth,
Math.max(activeResize.minWidth - activeResize.startWidth, rawDelta)
const resized = resizeDataGridColumn(
columns,
activeResize.baseWidths,
activeResize.columnId,
rawDelta,
activeResize.behavior,
activeResize.uncompensatedShrinkRoom
);
const nextWidths = { ...activeResize.baseWidths };
let nextBufferWidth = activeResize.bufferStartWidth;
let appliedActiveDelta = activeDelta;
if (activeResize.behavior === "cover" && activeDelta < 0) {
const requestedShrink = -activeDelta;
const availableUncompensatedShrink = activeResize.isLastResizableColumn ?
activeResize.lastResizableShrinkRoom :
activeResize.shrinkRoomWithoutScroll;
const freeShrink = Math.min(requestedShrink, availableUncompensatedShrink);
const compensationNeeded = requestedShrink - freeShrink;
const distribution = distributeWithFallback(
compensationNeeded,
activeResize.rightResizeTargets,
activeResize.bufferTarget
);
appliedActiveDelta = -(freeShrink + distribution.applied);
const applied = applyResizeAmounts(nextWidths, nextBufferWidth, distribution.amounts);
nextBufferWidth = applied.bufferWidth;
} else if (activeResize.behavior === "cover" && activeDelta > 0 && activeResize.bufferTarget) {
// The buffer is residual cover space, not user-sized content. Growing a
// real column consumes that track before the table itself gets wider.
const distribution = distributeResizeAmount(-activeDelta, [activeResize.bufferTarget]);
const applied = applyResizeAmounts(nextWidths, nextBufferWidth, distribution.amounts);
nextBufferWidth = applied.bufferWidth;
} else if (activeResize.behavior === "constrained") {
const distribution = distributeWithFallback(
-activeDelta,
activeResize.rightResizeTargets,
activeResize.bufferTarget
);
appliedActiveDelta = -distribution.applied;
const applied = applyResizeAmounts(nextWidths, nextBufferWidth, distribution.amounts);
nextBufferWidth = applied.bufferWidth;
}
nextWidths[activeResize.columnId] = activeResize.startWidth + appliedActiveDelta;
setState((current) => ({
...current,
widths: roundWidthRecord(nextWidths),
userWidths: {
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
[activeResize.columnId]: Math.round(nextWidths[activeResize.columnId] * 100) / 100
},
widths: resized.widths,
userWidths: activeResize.behavior === "free"
? {
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
[activeResize.columnId]: resized.widths[activeResize.columnId]
}
: resized.widths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: normalizeBufferWidth(nextBufferWidth, containerResizeColumns.length === 0)
fillColumnId: undefined
}));
}
@@ -491,7 +447,7 @@ export default function DataGrid<T>({
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length, layoutSignature]);
}, [resizeState, columns, effectiveResizeBehavior, layoutSignature]);
useEffect(() => {
if (!openFilterColumnId) return undefined;
@@ -594,13 +550,8 @@ export default function DataGrid<T>({
if (pagination && pagination.page !== paginationPage) pagination.onPageChange(paginationPage);
}, [pagination, paginationPage]);
const bufferWidth = Math.max(0, state.bufferWidth ?? 0);
const shouldRenderBuffer = effectiveResizeBehavior !== "free" && (
containerResizeColumns.length === 0 || bufferWidth > 0.01);
const actualTracks = columns.map((column) => widthForColumn(column, state.widths?.[column.id]));
const templateTracks = [...actualTracks];
if (shouldRenderBuffer) templateTracks.splice(bufferInsertIndex, 0, `${bufferWidth}px`);
const templateColumns = templateTracks.join(" ");
const templateColumns = actualTracks.join(" ");
const hasFlexibleColumns = columns.some((column) => !state.widths?.[column.id] && isFlexibleColumn(column));
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
const gridClassName = [
@@ -655,17 +606,6 @@ export default function DataGrid<T>({
});
}
function bufferCell(key: string, classNames: string, role: "columnheader" | "cell") {
return (
<div
key={key}
role={role}
aria-hidden="true"
className={`data-grid-cell data-grid-buffer-cell ${classNames}`.trim()} />);
}
return (
<div
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${className}`.trim()}
@@ -674,14 +614,10 @@ export default function DataGrid<T>({
<div className="data-grid-scroll-region" ref={scrollRegionRef}>
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns }}>
{columns.flatMap((column, columnIndex) => {
{columns.map((column, columnIndex) => {
const sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined;
const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim());
const cells: ReactNode[] = [];
if (shouldRenderBuffer && bufferInsertIndex === columnIndex) {
cells.push(bufferCell("header-buffer", "data-grid-header-cell", "columnheader"));
}
cells.push(
return (
<div
key={`header-${column.id}`}
role="columnheader"
@@ -721,57 +657,28 @@ export default function DataGrid<T>({
event.preventDefault();
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 activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
const rightResizeTargets = columns.
slice(activeColumnIndex + 1).
filter(isResizeCompensationColumn).
map((candidate) => resizeTargetForColumn(candidate, baseWidths));
const isLastResizableColumn = !columns.
slice(activeColumnIndex + 1).
some(isResizeCompensationColumn);
const canUseBuffer = effectiveResizeBehavior !== "free" && activeColumnIndex < bufferInsertIndex;
const storedBufferWidth = Math.max(0, state.bufferWidth ?? 0);
const scrollElement = scrollRegionRef.current;
const untrackedCoverWidth = scrollElement && canUseBuffer ?
Math.max(0, scrollElement.clientWidth - totalColumnWidth(columns, baseWidths) - storedBufferWidth) :
0;
const activeBufferWidth = storedBufferWidth + untrackedCoverWidth;
const totalHorizontalOverflow = scrollElement ?
Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth) :
0;
const shrinkRoomWithoutScroll = scrollElement ?
const shrinkRoomWithoutScroll = scrollElement && !isLastResizableColumn ?
Math.max(0, totalHorizontalOverflow - scrollElement.scrollLeft) :
0;
const lastResizableShrinkRoom = Math.min(
totalHorizontalOverflow,
Math.max(0, currentWidth - effectiveColumnMinWidth(column))
);
totalHorizontalOverflow;
setState((current) => ({
...current,
widths: roundWidthRecord(baseWidths),
fillColumnId: undefined,
bufferWidth: normalizeBufferWidth(activeBufferWidth, containerResizeColumns.length === 0)
fillColumnId: undefined
}));
setResizeState({
columnId: column.id,
startX: event.clientX,
startWidth: currentWidth,
minWidth: effectiveColumnMinWidth(column),
maxWidth: effectiveColumnMaxWidth(column),
baseWidths,
rightResizeTargets,
bufferTarget: canUseBuffer ? {
columnId: BUFFER_COLUMN_ID,
startWidth: activeBufferWidth,
minWidth: 0,
maxWidth: BUFFER_MAX_WIDTH
} : undefined,
bufferStartWidth: activeBufferWidth,
shrinkRoomWithoutScroll,
lastResizableShrinkRoom,
isLastResizableColumn,
uncompensatedShrinkRoom: shrinkRoomWithoutScroll,
behavior: effectiveResizeBehavior
});
}}>
@@ -781,30 +688,25 @@ export default function DataGrid<T>({
}
</div>
);
return cells;
})}
{shouldRenderBuffer && bufferInsertIndex === columns.length ?
bufferCell("header-buffer-end", "data-grid-header-cell", "columnheader") :
null}
{renderedRows.length === 0 ? (() => {
const filteredEmpty = rows.length > 0 && hasActiveFilters;
const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
const actionLayoutIndex = actionColumnIndex + (shouldRenderBuffer && bufferInsertIndex <= actionColumnIndex ? 1 : 0);
if (!filteredEmpty && emptyAction && actionColumn && actionLayoutIndex > 0) {
if (!filteredEmpty && emptyAction && actionColumn && actionColumnIndex > 0) {
return (
<>
<div
className="data-grid-cell data-grid-body-cell data-grid-empty-message data-grid-row-even is-last-row"
role="cell"
style={{ gridColumn: `1 / ${actionLayoutIndex + 1}` }}>
style={{ gridColumn: `1 / ${actionColumnIndex + 1}` }}>
{translatedEmptyText}
</div>
<div
className={`data-grid-cell data-grid-body-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
role="cell"
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionLayoutIndex + 1} / ${actionLayoutIndex + 2}` }}>
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}>
{emptyAction}
</div>
@@ -824,9 +726,6 @@ export default function DataGrid<T>({
const lastRowClass = visibleIndex === renderedRows.length - 1 ? "is-last-row" : "";
const cells: ReactNode[] = [];
columns.forEach((column, columnIndex) => {
if (shouldRenderBuffer && bufferInsertIndex === columnIndex) {
cells.push(bufferCell(`${rowKey}-buffer`, `data-grid-body-cell ${parityClass} ${lastRowClass}`, "cell"));
}
cells.push(
<div
key={`${rowKey}-${column.id}`}
@@ -838,9 +737,6 @@ export default function DataGrid<T>({
</div>
);
});
if (shouldRenderBuffer && bufferInsertIndex === columns.length) {
cells.push(bufferCell(`${rowKey}-buffer-end`, `data-grid-body-cell ${parityClass} ${lastRowClass}`, "cell"));
}
return cells;
})}
</div>
@@ -1369,7 +1265,9 @@ layoutSignature: string)
const column = columnMap.get(columnId);
if (!column || !Number.isFinite(width)) return [];
const minimum = effectiveColumnMinWidth(column);
const maximum = effectiveColumnMaxWidth(column, minimum);
const maximum = resizeBehavior === "free"
? effectiveColumnMaxWidth(column, minimum)
: DATA_GRID_MAX_TRACK_WIDTH;
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
})
);
@@ -1377,103 +1275,33 @@ layoutSignature: string)
const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {};
const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined;
const normalizedUserWidths = Object.keys(nextUserWidths).length > 0 ? roundWidthRecord(nextUserWidths) : undefined;
const hasContainerResizeColumn = columns.some(isContainerResizeColumn);
const normalizedBufferWidth = resizeBehavior === "free" || !signatureMatches ?
undefined :
normalizeBufferWidth(state.bufferWidth ?? 0, !hasContainerResizeColumn);
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
const bufferChanged = normalizedBufferWidth !== state.bufferWidth;
const fillChanged = state.fillColumnId !== undefined;
const signatureChanged = state.layoutSignature !== layoutSignature;
if (!widthsChanged && !userWidthsChanged && !bufferChanged && !fillChanged && !signatureChanged) return state;
if (!widthsChanged && !userWidthsChanged && !fillChanged && !signatureChanged) return state;
return {
...state,
widths: normalizedWidths,
userWidths: normalizedUserWidths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: normalizedBufferWidth
fillColumnId: undefined
};
}
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
const minimum = effectiveColumnMinWidth(column);
const maximum = effectiveColumnMaxWidth(column, minimum);
if (savedWidth !== undefined) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
if (savedWidth !== undefined) return `${Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(minimum, savedWidth))}px`;
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
if (column.width) return columnTrackWithMinimum(column.width, minimum);
return `minmax(${minimum}px, 1fr)`;
}
function findBufferInsertIndex<T>(columns: DataGridColumn<T>[]): number {
let index = columns.length;
while (index > 0 && columns[index - 1].sticky === "end") index -= 1;
return index;
}
function isContainerResizeColumn<T>(column: DataGridColumn<T>): boolean {
return isFlexibleWidth(column.width, column.fill);
}
function isResizeCompensationColumn<T>(column: DataGridColumn<T>): boolean {
return Boolean(column.resizable && !column.sticky);
}
function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<string, number>): ResizeTarget {
const minimum = effectiveColumnMinWidth(column);
return {
columnId: column.id,
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
minWidth: minimum,
maxWidth: effectiveColumnMaxWidth(column, minimum),
weight: dataGridColumnResizeWeight(column)
};
}
function distributeWithFallback(
requestedAmount: number,
targets: ResizeTarget[],
fallback?: ResizeTarget)
: {applied: number;amounts: Record<string, number>;} {
if (!fallback) return distributeResizeAmount(requestedAmount, targets);
// The synthetic buffer is residual coverage, not a peer of the data
// columns. Positive space is offered to real resizable columns first and
// reaches the buffer only after they hit maxWidth. Negative space consumes
// the buffer first; only the remaining deficit is distributed across real
// columns. This keeps a buffer created on a wide viewport from surviving a
// later viewport shrink while ordinary columns are reduced around it.
const bufferFirst = requestedAmount < 0 && fallback.startWidth > fallback.minWidth + 0.01;
const primaryTargets = bufferFirst ? [fallback] : targets;
const secondaryTargets = bufferFirst ? targets : [fallback];
const primary = distributeResizeAmount(requestedAmount, primaryTargets);
const remaining = requestedAmount - primary.applied;
if (Math.abs(remaining) <= 0.01 || secondaryTargets.length === 0) return primary;
const secondary = distributeResizeAmount(remaining, secondaryTargets);
return {
applied: primary.applied + secondary.applied,
amounts: { ...primary.amounts, ...secondary.amounts }
};
}
function applyResizeAmounts(
widths: Record<string, number>,
bufferWidth: number,
amounts: Record<string, number>)
: {bufferWidth: number;} {
for (const [columnId, amount] of Object.entries(amounts)) {
if (columnId === BUFFER_COLUMN_ID) bufferWidth += amount;else
widths[columnId] = (widths[columnId] ?? 0) + amount;
}
return { bufferWidth: Math.max(0, bufferWidth) };
}
function normalizeBufferWidth(width: number, preserveZero: boolean): number | undefined {
const normalized = Math.round(Math.max(0, width) * 100) / 100;
return normalized > 0.01 || preserveZero ? normalized : undefined;
}
function measuredColumnWidths<T>(
columns: DataGridColumn<T>[],
elements: Record<string, HTMLDivElement | null>,
@@ -1484,11 +1312,14 @@ measuredWidths?: Record<string, number>)
for (const column of columns) {
const element = elements[column.id];
const measured = element ? Math.round(element.getBoundingClientRect().width) : undefined;
result[column.id] = columnPixelWidth(
column,
savedWidths?.[column.id],
measured && measured > 0 ? measured : measuredWidths?.[column.id]
);
const savedWidth = savedWidths?.[column.id];
result[column.id] = savedWidth !== undefined
? Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(effectiveColumnMinWidth(column), savedWidth))
: columnPixelWidth(
column,
undefined,
measured && measured > 0 ? measured : measuredWidths?.[column.id]
);
}
return result;
}
@@ -1504,7 +1335,7 @@ function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Rec
columns.forEach((column, index) => {
if (column.sticky === "start") {
offsets[index] = left;
left += columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
left += renderedColumnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
}
});
let right = 0;
@@ -1512,12 +1343,22 @@ function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Rec
const index = columns.length - 1 - reverseIndex;
if (column.sticky === "end") {
offsets[index] = right;
right += columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
right += renderedColumnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
}
});
return offsets;
}
function renderedColumnPixelWidth<T>(
column: DataGridColumn<T>,
savedWidth?: number,
measuredWidth?: number
): number {
return savedWidth !== undefined
? Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(effectiveColumnMinWidth(column), savedWidth))
: columnPixelWidth(column, undefined, measuredWidth);
}
function stickyClass<T>(column: DataGridColumn<T>): string {
if (column.sticky === "start") return "is-sticky-start";
if (column.sticky === "end") return "is-sticky-end";
+227 -25
View File
@@ -19,12 +19,19 @@ export type DataGridResizeTarget = {
weight?: number;
};
export type DataGridFitMode = "free" | "cover" | "constrained";
export type DataGridColumnLayout = {
widths: Record<string, number>;
bufferWidth: number;
underflowWidth: number;
overflowWidth: number;
};
export type DataGridColumnResize = {
widths: Record<string, number>;
appliedDelta: number;
};
const MIN_HEADER_LABEL_WIDTH = 72;
const SORT_CONTROL_RESERVE = 24;
const FILTER_CONTROL_RESERVE = 32;
@@ -125,7 +132,8 @@ export function fitDataGridColumns(
columns: DataGridSizingColumn[],
containerWidth: number,
measuredWidths: Record<string, number> = {},
userWidths: Record<string, number> = {}
userWidths: Record<string, number> = {},
fitMode: DataGridFitMode = "cover"
): DataGridColumnLayout {
const safeContainerWidth = Math.max(0, containerWidth);
const widths: Record<string, number> = {};
@@ -135,37 +143,160 @@ export function fitDataGridColumns(
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
const override = userWidths[column.id];
widths[column.id] = override !== undefined && Number.isFinite(override)
? clampWidth(override, minimum, maximum)
? clampWidth(
override,
minimum,
fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : maximum
)
: preferredDataGridColumnWidth(column, safeContainerWidth, measuredWidths[column.id]);
}
const preferredTotal = totalDataGridColumnWidth(columns, widths);
const requestedAmount = safeContainerWidth - preferredTotal;
const targets = columns
.filter((column) => userWidths[column.id] === undefined)
.filter((column) => requestedAmount >= 0
? dataGridColumnGrowthWeight(column) > 0
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
.map((column): DataGridResizeTarget => ({
columnId: column.id,
startWidth: widths[column.id],
minWidth: effectiveDataGridColumnMinWidth(column),
maxWidth: effectiveDataGridColumnMaxWidth(column),
weight: requestedAmount >= 0
? dataGridColumnGrowthWeight(column)
: dataGridColumnResizeWeight(column)
}));
const distribution = distributeDataGridResizeAmount(requestedAmount, targets);
let remaining = safeContainerWidth - preferredTotal;
const ordinaryColumns = columns.filter((column) => !column.sticky);
const coverageColumns = ordinaryColumns.length > 0 ? ordinaryColumns : columns;
const automaticColumns = coverageColumns.filter((column) => userWidths[column.id] === undefined);
const responsiveColumns = automaticColumns.filter((column) =>
isFlexibleDataGridWidth(column.width, column.fill)
);
for (const [columnId, amount] of Object.entries(distribution.amounts)) {
widths[columnId] += amount;
if (fitMode === "free") {
const targets = automaticColumns
.filter((column) => remaining >= 0
? dataGridColumnGrowthWeight(column) > 0
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
.map((column) => resizeTargetForLayout(
column,
widths,
remaining >= 0 ? dataGridColumnGrowthWeight(column) : dataGridColumnResizeWeight(column)
));
remaining = applyDataGridDistribution(widths, remaining, targets);
} else if (remaining > 0.01) {
remaining = applyDataGridDistribution(
widths,
remaining,
responsiveColumns.map((column) =>
resizeTargetForLayout(column, widths, dataGridColumnGrowthWeight(column))
)
);
remaining = applyDataGridDistribution(
widths,
remaining,
automaticColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id])
)
);
// Cover is a stronger invariant than maxWidth. Once preferred maxima have
// been exhausted, ordinary data columns absorb the final residual space.
remaining = applyDataGridDistribution(
widths,
remaining,
coverageColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id], DATA_GRID_MAX_TRACK_WIDTH)
)
);
} else if (remaining < -0.01) {
remaining = applyDataGridDistribution(
widths,
remaining,
responsiveColumns
.filter((column) => column.resizable || isFlexibleDataGridWidth(column.width, column.fill))
.map((column) => resizeTargetForLayout(column, widths, widths[column.id]))
);
remaining = applyDataGridDistribution(
widths,
remaining,
automaticColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id])
)
);
// A persisted user layout is preferred, not a reason to overflow. Under
// viewport pressure every ordinary track may shrink down to its hard floor.
remaining = applyDataGridDistribution(
widths,
remaining,
coverageColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id])
)
);
}
const remaining = requestedAmount - distribution.applied;
const roundedWidths = roundDataGridWidths(widths);
closeCoveredRoundingGap(columns, roundedWidths, safeContainerWidth, fitMode, remaining);
const renderedTotal = totalDataGridColumnWidth(columns, roundedWidths);
const renderedDifference = safeContainerWidth - renderedTotal;
return {
widths: roundedWidths,
underflowWidth: roundWidth(Math.max(0, renderedDifference)),
overflowWidth: roundWidth(Math.max(0, -renderedDifference))
};
}
export function resizeDataGridColumn(
columns: DataGridSizingColumn[],
baseWidths: Record<string, number>,
columnId: string,
requestedDelta: number,
fitMode: DataGridFitMode,
uncompensatedShrinkRoom = 0
): DataGridColumnResize {
const activeIndex = columns.findIndex((column) => column.id === columnId);
const activeColumn = columns[activeIndex];
if (!activeColumn) return { widths: roundDataGridWidths(baseWidths), appliedDelta: 0 };
const startWidth = baseWidths[columnId]
?? dataGridColumnPixelWidth(activeColumn);
const minimum = effectiveDataGridColumnMinWidth(activeColumn);
// Cover fitting may have expanded this track beyond its preferred maximum.
// A pointer move must never snap such a track backwards before applying the
// user's requested direction.
const maximum = Math.max(
startWidth,
effectiveDataGridColumnMaxWidth(activeColumn, minimum)
);
const boundedDelta = Math.min(
maximum - startWidth,
Math.max(minimum - startWidth, requestedDelta)
);
const widths = { ...baseWidths };
if (fitMode === "free") {
widths[columnId] = startWidth + boundedDelta;
return {
widths: roundDataGridWidths(widths),
appliedDelta: roundWidth(boundedDelta)
};
}
const freeShrink = boundedDelta < 0
? Math.min(-boundedDelta, Math.max(0, uncompensatedShrinkRoom))
: 0;
const uncompensatedDelta = -freeShrink;
const compensationRequest = -(boundedDelta - uncompensatedDelta);
let remainingCompensation = compensationRequest;
let appliedCompensation = 0;
for (const group of resizeCompensationGroups(columns, activeIndex)) {
if (Math.abs(remainingCompensation) <= 0.01) break;
const targets = group.map((column) =>
resizeTargetForLayout(
column,
widths,
dataGridColumnResizeWeight(column),
fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : undefined
)
);
const distribution = distributeDataGridResizeAmount(remainingCompensation, targets);
applyDataGridAmounts(widths, distribution.amounts);
appliedCompensation += distribution.applied;
remainingCompensation -= distribution.applied;
}
const appliedDelta = uncompensatedDelta - appliedCompensation;
widths[columnId] = startWidth + appliedDelta;
return {
widths: roundDataGridWidths(widths),
bufferWidth: Math.round(Math.max(0, remaining) * 100) / 100,
overflowWidth: Math.round(Math.max(0, -remaining) * 100) / 100
appliedDelta: roundWidth(appliedDelta)
};
}
@@ -261,7 +392,74 @@ function dataGridColumnGrowthWeight(column: DataGridSizingColumn): number {
if (column.fill || column.width === undefined) return 1;
if (typeof column.width !== "string") return 0;
const preferred = minmaxParts(column.width)?.preferred ?? column.width;
return parseFractionTrack(preferred) ?? 0;
return parseFractionTrack(preferred) ?? parsePercentageTrack(preferred) ?? 0;
}
function resizeTargetForLayout(
column: DataGridSizingColumn,
widths: Record<string, number>,
weight: number,
maximum?: number
): DataGridResizeTarget {
const minimum = effectiveDataGridColumnMinWidth(column);
return {
columnId: column.id,
startWidth: widths[column.id] ?? dataGridColumnPixelWidth(column),
minWidth: minimum,
maxWidth: maximum ?? effectiveDataGridColumnMaxWidth(column, minimum),
weight
};
}
function applyDataGridDistribution(
widths: Record<string, number>,
requestedAmount: number,
targets: DataGridResizeTarget[]
): number {
if (Math.abs(requestedAmount) <= 0.01 || targets.length === 0) return requestedAmount;
const distribution = distributeDataGridResizeAmount(requestedAmount, targets);
applyDataGridAmounts(widths, distribution.amounts);
return requestedAmount - distribution.applied;
}
function applyDataGridAmounts(
widths: Record<string, number>,
amounts: Record<string, number>
): void {
for (const [columnId, amount] of Object.entries(amounts)) {
widths[columnId] = (widths[columnId] ?? 0) + amount;
}
}
function resizeCompensationGroups(
columns: DataGridSizingColumn[],
activeIndex: number
): DataGridSizingColumn[][] {
const right = columns.slice(activeIndex + 1).filter((column) => !column.sticky);
const left = columns.slice(0, activeIndex).reverse().filter((column) => !column.sticky);
return [
right.filter((column) => column.resizable),
right.filter((column) => !column.resizable),
left.filter((column) => column.resizable),
left.filter((column) => !column.resizable)
].filter((group) => group.length > 0);
}
function closeCoveredRoundingGap(
columns: DataGridSizingColumn[],
widths: Record<string, number>,
containerWidth: number,
fitMode: DataGridFitMode,
unallocatedAmount: number
): void {
if (fitMode !== "cover" || Math.abs(unallocatedAmount) > 0.01) return;
const roundedTotal = totalDataGridColumnWidth(columns, widths);
const correction = roundWidth(containerWidth - roundedTotal);
if (Math.abs(correction) <= 0.001) return;
const ordinaryColumns = columns.filter((column) => !column.sticky);
const target = ordinaryColumns[ordinaryColumns.length - 1] ?? columns[columns.length - 1];
if (!target) return;
widths[target.id] = roundWidth((widths[target.id] ?? 0) + correction);
}
function parsePixelTrack(track?: string): number | null {
@@ -301,3 +499,7 @@ function normalizedWeight(weight?: number): number {
function clampWidth(width: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, width));
}
function roundWidth(width: number): number {
return Math.round(width * 100) / 100;
}
+3
View File
@@ -32,6 +32,7 @@ export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";
export * from "./platform/ViewContext";
export * from "./platform/views";
export * from "./platform/wizards";
export * from "./utils/permissions";
export * from "./utils/fieldHelp";
@@ -85,6 +86,8 @@ export { default as DisabledActionTooltip } from "./components/DisabledActionToo
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
export { default as WizardDirectory } from "./components/WizardDirectory";
export type { WizardDirectoryProps } from "./components/WizardDirectory";
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
export { default as FileDropZone } from "./components/FileDropZone";
+31
View File
@@ -0,0 +1,31 @@
import type {
WizardDirectoriesUiCapability,
WizardDirectoryContext,
WizardDirectoryEntry
} from "../types";
export function wizardEntriesForContext(
capabilities: WizardDirectoriesUiCapability[],
context: WizardDirectoryContext
): WizardDirectoryEntry[] {
const entries = capabilities
.flatMap((capability) => capability.directories)
.filter((directory) => directory.contextId === context.contextId)
.sort(
(left, right) =>
(left.order ?? 100) - (right.order ?? 100)
|| left.id.localeCompare(right.id)
)
.flatMap((directory) => directory.entries(context));
const unique = new Map<string, WizardDirectoryEntry>();
for (const entry of entries) {
const key = `${entry.moduleId}:${entry.id}`;
if (!unique.has(key)) unique.set(key, entry);
}
return [...unique.values()].sort(
(left, right) =>
(left.order ?? 100) - (right.order ?? 100)
|| left.label.localeCompare(right.label)
);
}
+15
View File
@@ -165,6 +165,21 @@
.wizard-heading h1 { margin: 0; }
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
.wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.wizard-directory-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 16px;
align-items: start;
}
.wizard-directory-grid > .card {
min-height: 164px;
}
.wizard-directory-grid > .card .card-body {
color: var(--muted);
}
.wizard-directory-grid > .card .card-body p {
margin: 0;
}
.stepper { list-style: none; padding: 22px 0; margin: 0; background: var(--panel-soft); border-right: var(--border-line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: var(--muted); cursor: pointer; }
.step.active button { background: var(--surface); color: var(--text-strong); }
-9
View File
@@ -226,15 +226,6 @@
min-width: 100%;
}
.data-grid-buffer-cell {
min-width: 0;
max-width: none;
padding: 0;
gap: 0;
border-right: 0;
pointer-events: none;
}
.data-grid-container .data-grid {
min-height: 100%;
}
+32
View File
@@ -266,6 +266,38 @@ export type PlatformRouteContext = {
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
};
export type WizardDirectoryContext = PlatformRouteContext & {
contextId: string;
resourceId?: string;
};
export type WizardDirectoryEntry = {
id: string;
moduleId: string;
label: string;
description?: string;
to: string;
actionLabel?: string;
category?: string;
order?: number;
anyOf?: string[];
allOf?: string[];
available?: boolean;
unavailableReason?: string;
};
export type WizardDirectoryContribution = {
id: string;
moduleId: string;
contextId: string;
order?: number;
entries: (context: WizardDirectoryContext) => WizardDirectoryEntry[];
};
export type WizardDirectoriesUiCapability = {
directories: WizardDirectoryContribution[];
};
export type PlatformPublicRouteContext = {
settings: ApiSettings;
auth: AuthInfo | null;
+18
View File
@@ -129,3 +129,21 @@ const externalShortcutMarkup = renderToStaticMarkup(
);
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");
const fixedCoverMarkup = renderToStaticMarkup(
<DataGrid
id="fixed-cover-regression"
rows={[{ id: "row", first: "First", second: "Second" }]}
columns={[
{ id: "first", header: "First", width: 100, value: (row) => row.first },
{ id: "second", header: "Second", width: 200, value: (row) => row.second },
{ id: "actions", header: "Actions", width: 72, sticky: "end", value: () => "" }
]}
getRowKey={(row) => row.id}
/>
);
assertEqual(
fixedCoverMarkup.includes("data-grid-buffer-cell"),
false,
"cover grids never render a synthetic blank column"
);
+131 -16
View File
@@ -4,6 +4,7 @@ import {
distributeDataGridResizeAmount,
effectiveDataGridColumnMinWidth,
fitDataGridColumns,
resizeDataGridColumn,
type DataGridSizingColumn
} from "../src/components/table/dataGridSizing";
@@ -45,7 +46,7 @@ assertWidths(
{ fixed: 100, primary: 200, secondary: 300 },
"fraction tracks receive residual width by declared weight"
);
assertEqual(weightedLayout.bufferWidth, 0, "fraction tracks cover the available container");
assertEqual(weightedLayout.underflowWidth, 0, "fraction tracks cover the available container");
const boundedLayout = fitDataGridColumns([
{ id: "fixed", width: 100 },
@@ -54,10 +55,10 @@ const boundedLayout = fitDataGridColumns([
], 600);
assertWidths(
boundedLayout.widths,
{ fixed: 100, primary: 150, secondary: 200 },
"fraction tracks stop at their declared maxima"
{ fixed: 250, primary: 150, secondary: 200 },
"real columns absorb cover space after fraction tracks reach their preferred maxima"
);
assertEqual(boundedLayout.bufferWidth, 150, "space beyond column maxima becomes the synthetic buffer");
assertEqual(boundedLayout.underflowWidth, 0, "cover layouts never create a synthetic filler track");
const overflowLayout = fitDataGridColumns([
{ id: "fixed", width: 200 },
@@ -66,10 +67,10 @@ const overflowLayout = fitDataGridColumns([
], 600);
assertWidths(
overflowLayout.widths,
{ fixed: 200, primary: 320, secondary: 260 },
"columns never shrink below declared minima"
{ fixed: 72, primary: 320, secondary: 260 },
"ordinary fixed tracks also shrink before a cover layout overflows"
);
assertEqual(overflowLayout.overflowWidth, 180, "minimum-width excess remains horizontal overflow");
assertEqual(overflowLayout.overflowWidth, 52, "hard minimum-width excess remains horizontal overflow");
const percentageLayout = fitDataGridColumns([
{ id: "fixed", width: 100 },
@@ -78,10 +79,10 @@ const percentageLayout = fitDataGridColumns([
], 1000);
assertWidths(
percentageLayout.widths,
{ fixed: 100, primary: 300, secondary: 200 },
"percentage tracks retain their preferred container share"
{ fixed: 100, primary: 540, secondary: 360 },
"percentage tracks absorb residual cover space proportionally"
);
assertEqual(percentageLayout.bufferWidth, 400, "unused percentage space remains a neutral buffer");
assertEqual(percentageLayout.underflowWidth, 0, "percentage layouts cover the complete container");
const overriddenLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 450 });
assertWidths(
@@ -93,20 +94,20 @@ assertWidths(
const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 });
assertWidths(
overriddenOverflowLayout.widths,
{ fixed: 100, primary: 450, secondary: 100 },
"a narrow viewport preserves the user override without squashing peers below their minima"
{ fixed: 72, primary: 328, secondary: 100 },
"a cover layout treats a persisted user width as preferred under viewport pressure"
);
assertEqual(
overriddenOverflowLayout.overflowWidth,
150,
"a user override that cannot fit becomes horizontal overflow"
0,
"a persisted user preference does not create avoidable horizontal overflow"
);
const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 });
const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 }, "free");
assertEqual(
clampedOverrideLayout.widths.primary,
500,
"user overrides remain bounded by the column maximum"
"free-layout user overrides remain bounded by the column maximum"
);
const redistributedGrowth = distributeDataGridResizeAmount(300, [
@@ -141,6 +142,120 @@ assertWidths(
"legacy fill columns remain stable under the explicit sizing contract"
);
const fixedCoverColumns: DataGridSizingColumn[] = [
{ id: "first", width: 100 },
{ id: "second", width: 200 },
{ id: "actions", width: 80, sticky: "end" }
];
const fixedCoverLayout = fitDataGridColumns(fixedCoverColumns, 760);
assertWidths(
fixedCoverLayout.widths,
{ first: 226.67, second: 453.33, actions: 80 },
"all-fixed cover grids stretch ordinary columns proportionally"
);
assertEqual(fixedCoverLayout.underflowWidth, 0, "all-fixed cover grids leave no blank track");
const softMaximumLayout = fitDataGridColumns([
{ id: "first", width: 100, maxWidth: 100 },
{ id: "second", width: 200, maxWidth: 200 },
{ id: "actions", width: 80, sticky: "end" }
], 680);
assertWidths(
softMaximumLayout.widths,
{ first: 200, second: 400, actions: 80 },
"coverage may exceed preferred maxima instead of rendering a filler column"
);
const resizeColumns: DataGridSizingColumn[] = [
{ id: "first", width: 200, minWidth: 100, maxWidth: 500, resizable: true },
{ id: "middle", width: 300, minWidth: 100, maxWidth: 500, resizable: true },
{ id: "last", width: 300, minWidth: 100, maxWidth: 500, resizable: true },
{ id: "actions", width: 100, sticky: "end" }
];
const resizeBase = { first: 200, middle: 300, last: 300, actions: 100 };
const resizedFirst = resizeDataGridColumn(resizeColumns, resizeBase, "first", 120, "cover");
assertWidths(
resizedFirst.widths,
{ first: 320, middle: 240, last: 240, actions: 100 },
"right-side peers compensate an earlier cover-column resize"
);
const resizedLast = resizeDataGridColumn(resizeColumns, resizeBase, "last", 120, "cover");
assertWidths(
resizedLast.widths,
{ first: 140, middle: 240, last: 420, actions: 100 },
"left-side peers compensate the last resizable cover column"
);
const committedLayout = fitDataGridColumns(
resizeColumns,
900,
{},
resizedLast.widths,
"cover"
);
assertWidths(
committedLayout.widths,
resizedLast.widths,
"committing a cover resize preserves the drag layout without a mouse-up snap"
);
const fixedPeerResize = resizeDataGridColumn([
{ id: "editable", width: 200, minWidth: 100, maxWidth: 500, resizable: true },
{ id: "fixed", width: 300 },
{ id: "actions", width: 100, sticky: "end" }
], { editable: 200, fixed: 300, actions: 100 }, "editable", 80, "cover");
assertWidths(
fixedPeerResize.widths,
{ editable: 280, fixed: 220, actions: 100 },
"non-resizable tracks may compensate layout without exposing a resize handle"
);
const coverBeyondPassiveMax = resizeDataGridColumn([
{ id: "first", width: 200, minWidth: 100, maxWidth: 200, resizable: true },
{ id: "second", width: 300, minWidth: 100, maxWidth: 300, resizable: true },
{ id: "actions", width: 100, sticky: "end" }
], { first: 200, second: 300, actions: 100 }, "first", -80, "cover");
assertWidths(
coverBeyondPassiveMax.widths,
{ first: 120, second: 380, actions: 100 },
"cover compensation may exceed a peer's preferred maximum to retain full coverage"
);
const constrainedAtPassiveMax = resizeDataGridColumn([
{ id: "first", width: 200, minWidth: 100, maxWidth: 200, resizable: true },
{ id: "second", width: 300, minWidth: 100, maxWidth: 300, resizable: true },
{ id: "actions", width: 100, sticky: "end" }
], { first: 200, second: 300, actions: 100 }, "first", -80, "constrained");
assertWidths(
constrainedAtPassiveMax.widths,
{ first: 200, second: 300, actions: 100 },
"constrained compensation stops when every peer is at its hard maximum"
);
const initiallyExpandedColumn = resizeDataGridColumn([
{ id: "first", width: 100, minWidth: 72, maxWidth: 100, resizable: true },
{ id: "second", width: 200, minWidth: 72, resizable: true }
], { first: 180, second: 320 }, "first", 20, "cover");
assertEqual(
initiallyExpandedColumn.widths.first,
180,
"a cover-expanded active track does not snap backwards on a growth drag"
);
const freeResize = resizeDataGridColumn(
resizeColumns,
resizeBase,
"last",
120,
"free"
);
assertWidths(
freeResize.widths,
{ first: 200, middle: 300, last: 420, actions: 100 },
"free resizing changes only the active track"
);
const originalSignature = dataGridLayoutSignature(weightedColumns, "container", "cover");
const changedSignature = dataGridLayoutSignature(
weightedColumns.map((column) => column.id === "primary" ? { ...column, maxWidth: 420 } : column),
+2
View File
@@ -34,6 +34,7 @@ const defaultWebModulePackages = [
"@govoplan/risk-compliance-webui",
"@govoplan/scheduling-webui",
"@govoplan/search-webui",
"@govoplan/tenancy-webui",
"@govoplan/views-webui",
"@govoplan/workflow-webui"
];
@@ -236,6 +237,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
]