8 Commits

16 changed files with 612 additions and 30 deletions

View File

@@ -5,7 +5,11 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan

View File

@@ -233,6 +233,15 @@ instead of reproducing their behavior.
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
complete logical row set and applies filtering and sorting before slicing a
page. Server mode receives only the loaded page, requires `onQueryChange`,
and the backend applies every emitted filter/sort before pagination while
returning `totalRows` for the filtered result. Server list filters declare
their complete option domain instead of deriving it from the loaded page.
External filter affordances such as summary-count shortcuts update the
grid's `query` contract; the grid header controls and backend query therefore
always display and execute the same filter state.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.

View File

@@ -291,6 +291,254 @@
"release": "0.1.11",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T07:06:21Z",
"release": "0.1.12",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.11"
version = "0.1.13"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"

View File

@@ -7,6 +7,7 @@ import logging
import os
from pathlib import Path
import re
import sysconfig
from typing import Any
from alembic import command
@@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
installed_runtime_root,
]
for candidate in candidates:
if candidate is None:

143
tests/test_wheel_runtime.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import sysconfig
import tempfile
import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
wheel_directory.mkdir()
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
venv_python = virtual_environment / "bin" / "python"
self._run(
str(venv_python),
"-m",
"pip",
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
database_path = temporary_root / "wheel-runtime.db"
probe = self._run(
str(venv_python),
"-c",
self._probe_script(),
str(database_path),
cwd=temporary_root,
env={
**os.environ,
# Reuse only third-party test dependencies from the parent
# environment. Editable .pth files are not processed from
# PYTHONPATH, so Core itself can only come from the wheel.
"PYTHONPATH": sysconfig.get_path("purelib"),
"GOVOPLAN_CORE_SOURCE_ROOT": "",
"GOVOPLAN_ENABLED_MODULES": "",
},
)
result = json.loads(probe.stdout)
installed_package = Path(result["package_file"])
runtime_root = Path(result["runtime_root"])
self.assertTrue(installed_package.is_relative_to(virtual_environment))
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])
@staticmethod
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
env=env,
check=False,
capture_output=True,
text=True,
)
if result.returncode:
raise AssertionError(
f"Command failed ({result.returncode}): {' '.join(command)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
@staticmethod
def _probe_script() -> str:
return """
import json
from pathlib import Path
import sys
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect
import govoplan_core
from govoplan_core.db.migrations import _repo_root, alembic_config
database_url = f"sqlite:///{Path(sys.argv[1])}"
config = alembic_config(database_url=database_url, enabled_modules=())
command.upgrade(config, "heads")
script = ScriptDirectory.from_config(config)
engine = create_engine(database_url)
try:
with engine.connect() as connection:
tables = sorted(inspect(connection).get_table_names())
finally:
engine.dispose()
print(json.dumps({
"package_file": govoplan_core.__file__,
"runtime_root": str(_repo_root()),
"heads": sorted(script.get_heads()),
"tables": tables,
}))
"""
if __name__ == "__main__":
unittest.main()

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.11",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.11",
"version": "0.1.13",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",

View File

@@ -1,18 +1,18 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.11",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.11",
"version": "0.1.13",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
@@ -787,13 +787,13 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
"version": "0.1.10",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
"dependencies": {
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.11",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -29,6 +29,7 @@
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.11",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -31,7 +31,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"

View File

@@ -1,9 +1,14 @@
import { usePlatformLanguage } from "../i18n/LanguageContext";
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
const { translateText } = usePlatformLanguage();
const renderedValue = typeof value === "string" ? translateText(value) : value;
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
<div className="metric-label">{translateText(label)}</div>
<div className="metric-value">{renderedValue}</div>
{detail && <div className="metric-detail">{translateText(detail)}</div>}
</div>
);
}

View File

@@ -30,18 +30,36 @@ export type DataGridQueryState = {
filters: Record<string, string>;
};
export type DataGridPagination = {
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
mode?: "client" | "server";
type DataGridPaginationBase = {
page: number;
pageSize: number;
totalRows?: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
disabled?: boolean;
};
export type DataGridClientPagination = DataGridPaginationBase & {
/**
* Client mode filters and sorts before slicing. `rows` must therefore contain
* the complete logical result set, never an already paginated backend page.
*/
mode?: "client";
totalRows?: never;
};
export type DataGridServerPagination = DataGridPaginationBase & {
/**
* Server mode treats `rows` as the current backend page. The query callback
* must apply every emitted sort/filter to the backend before pagination.
*/
mode: "server";
/** Total rows after the backend has applied the current filters. */
totalRows: number;
};
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
export type DataGridColumn<T> = {
@@ -78,7 +96,7 @@ type DataGridState = {
bufferWidth?: number;
};
type DataGridProps<T> = {
type DataGridBaseProps<T> = {
id: string;
rows: T[];
columns: DataGridColumn<T>[];
@@ -98,11 +116,24 @@ type DataGridProps<T> = {
storageKey?: string;
initialFilters?: Record<string, string | string[]>;
initialSort?: {columnId: string;direction: DataGridSortDirection;};
pagination?: DataGridPagination;
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
onQueryChange?: (query: DataGridQueryState) => void;
/**
* Synchronize a query selected outside the grid, for example by a summary
* count shortcut. Server grids should feed `onQueryChange` back into this
* value so the header controls and backend query remain in agreement.
*/
query?: DataGridQueryState;
};
/**
* Query ownership is deliberately explicit: client grids receive all rows and
* DataGrid queries them locally; server grids receive one page and must handle
* the complete query through `onQueryChange`.
*/
export type DataGridProps<T> = DataGridBaseProps<T> & (
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
);
export type DataGridRowActionsProps = {
disabled?: boolean;
removeDisabled?: boolean;
@@ -179,6 +210,7 @@ export default function DataGrid<T>({
storageKey,
initialFilters = {},
initialSort,
query,
pagination,
onQueryChange
}: DataGridProps<T>) {
@@ -205,7 +237,15 @@ export default function DataGrid<T>({
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
const externalQueryKey = query ? JSON.stringify(query) : "";
const normalizedExternalQuery = useMemo(
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
[externalQueryKey]
);
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
normalizedExternalQuery
));
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
@@ -213,14 +253,21 @@ export default function DataGrid<T>({
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
const serverQueryMode = pagination?.mode === "server";
const onQueryChangeRef = useRef(onQueryChange);
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
const lastLayoutSignatureRef = useRef<string | null>(null);
const lastContainerWidthRef = useRef<number | undefined>(undefined);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
const serverQueryMode = pagination?.mode === "server";
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
useEffect(() => {
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
if (!serverQueryMode) lastServerQueryRef.current = null;
}, [pagination, serverQueryMode]);
useEffect(() => {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
}, [columns, effectiveResizeBehavior]);
@@ -233,6 +280,10 @@ export default function DataGrid<T>({
setState((current) => mergeInitialSort(current, normalizedInitialSort));
}, [normalizedInitialSort]);
useEffect(() => {
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
}, [normalizedExternalQuery]);
useEffect(() => {
try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -460,10 +511,17 @@ export default function DataGrid<T>({
useEffect(() => {
if (!serverQueryMode) return;
onQueryChangeRef.current?.({
const query = {
sort: state.sort ?? null,
filters: { ...(state.filters ?? {}) }
});
};
const previousQuery = lastServerQueryRef.current;
lastServerQueryRef.current = query;
const serverPagination = serverPaginationRef.current;
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
serverPagination.onPageChange(1);
}
onQueryChangeRef.current?.(query);
}, [serverQueryMode, state.sort, state.filters]);
const filterTypes = useMemo(() => {
@@ -1202,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
return { ...state, sort: initialSort };
}
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
if (!query) return state;
const current = {
sort: state.sort ?? null,
filters: state.filters ?? {}
};
if (dataGridQueriesEqual(current, query)) return state;
return {
...state,
sort: query.sort ? { ...query.sort } : null,
filters: { ...query.filters }
};
}
function formatListFilter(values: string[]): string {
return `list:${JSON.stringify([...new Set(values)])}`;
}
@@ -1668,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
}
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function stringifyCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);

View File

@@ -114,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
export { default as TableActionGroup } from "./components/table/TableActionGroup";
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";

View File

@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
import { renderToStaticMarkup } from "react-dom/server";
import Button from "../src/components/Button";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {}
@@ -77,3 +77,55 @@ const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission d
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
type FilterRow = { id: string; name: string };
const filterRows: FilterRow[] = [
{ id: "first-non-match", name: "Ordinary row" },
{ id: "second-non-match", name: "Another row" },
{ id: "first-match", name: "Target alpha" },
{ id: "third-non-match", name: "Unrelated row" },
{ id: "second-match", name: "Target beta" },
{ id: "third-match", name: "Target gamma" }
];
const filterColumns: DataGridColumn<FilterRow>[] = [
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
];
const fullResultFilterMarkup = renderToStaticMarkup(
<DataGrid
id="full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
initialFilters={{ name: "target" }}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
const clearedFilterMarkup = renderToStaticMarkup(
<DataGrid
id="cleared-full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");
const externalShortcutMarkup = renderToStaticMarkup(
<DataGrid
id="external-query-shortcut-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
query={{ sort: null, filters: { name: "target" } }}
/>
);
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");

View File

@@ -0,0 +1,26 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
import { renderToStaticMarkup } from "react-dom/server";
import MetricCard from "../src/components/MetricCard";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const translatedMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<MetricCard
label="i18n:govoplan-core.installed_modules.32b3e799"
value="i18n:govoplan-core.core_only.d46ce7d9"
detail="i18n:govoplan-core.install_the_campaign_module_to_show_campaign_act.3085f23e"
tone="info"
/>
</PlatformLanguageProvider>
);
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved");
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");

View File

@@ -23,6 +23,7 @@
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
"tests/mail-components.test.tsx",
"tests/metric-card.test.tsx",
"tests/people-picker.test.tsx",
"tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx",
@@ -30,6 +31,7 @@
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx",
"src/components/MetricCard.tsx",
"src/components/people/PeoplePicker.tsx",
"src/components/people/peoplePickerTypes.ts",
"src/components/ResourceAccessExplanation.tsx",
@@ -45,6 +47,7 @@
"src/components/IconButton.tsx",
"src/components/admin/AdminIconButton.tsx",
"src/components/ToggleSwitch.tsx",
"src/components/table/DataGrid.tsx",
"src/components/table/TableActionGroup.tsx"
]
}