10 Commits
Author SHA1 Message Date
zemion bad3418a7c fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:04:03 +02:00
zemion a8bfdbf20c fix(packaging): expose immutable WebUI Git package for v0.1.20
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:10 +02:00
zemion f0a28dd8ec docs(projects): add German workflow guidance
Module Package Release / publish-packages (push) Successful in 10s
2026-08-23 01:54:20 +02:00
zemion de8ec81c47 feat(projects): add governed DSAR coverage 2026-08-21 13:28:07 +02:00
zemion 4bebb4211a refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 2618532377 feat: align projects with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion 139dc0cd75 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion e4663d545a Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion 801b11016c Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:51 +02:00
zemion eccf7dc207 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:13 +02:00
12 changed files with 1034 additions and 201 deletions
+18
View File
@@ -36,3 +36,21 @@ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
The module migration is applied through the platform migration runner when the
module is enabled. Destructive retirement requires the normal snapshot and
uninstall-guard process because it removes planning history and events.
## Git-source WebUI package
The repository root exposes `@govoplan/projects-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/projects-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+29 -3
View File
@@ -1,8 +1,34 @@
{
"name": "@govoplan/projects",
"version": "0.1.16",
"name": "@govoplan/projects-webui",
"version": "0.1.20",
"private": true,
"description": "GovOPlaN Projects domain module.",
"type": "module",
"peerDependencies": {}
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/projects.css": "./webui/src/styles/projects.css"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-projects"
version = "0.1.16"
version = "0.1.20"
description = "GovOPlaN Projects domain module."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.16",
"govoplan-access>=0.1.16",
"govoplan-core>=0.1.37",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
@@ -0,0 +1,456 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_projects.backend.db.models import (
ProjectMembershipGrant,
ProjectObjectEvent,
ProjectObjectIdentity,
ProjectObjectRevision,
)
PROJECTS_DSAR_CAPABILITY = dsar_capability_name("projects")
_MAX_RECORDS = 5_000
_MAX_PERMISSIONS = 100
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _Selectors:
actor_ids: tuple[str, ...]
membership_subjects: tuple[tuple[str, str], ...]
object_kind: str | None
object_id: str | None
class ProjectsDsarProvider:
provider_id = "projects"
module_id = "projects"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if selectors.membership_subjects:
membership_conditions = tuple(
and_(
ProjectMembershipGrant.subject_kind == kind,
ProjectMembershipGrant.subject_id == subject_id,
)
for kind, subject_id in selectors.membership_subjects
)
query = db.query(ProjectMembershipGrant).filter(
ProjectMembershipGrant.tenant_id == tenant_id,
or_(*membership_conditions),
)
query = _object_filter(query, ProjectMembershipGrant, selectors)
records.extend(
_membership_record(row)
for row in _limited(
query,
ProjectMembershipGrant.created_at,
ProjectMembershipGrant.id,
label="membership",
)
)
if selectors.actor_ids:
identities = db.query(ProjectObjectIdentity).filter(
ProjectObjectIdentity.tenant_id == tenant_id,
ProjectObjectIdentity.created_by.in_(selectors.actor_ids),
)
identities = _object_filter(identities, ProjectObjectIdentity, selectors)
records.extend(
_identity_actor_record(row)
for row in _limited(
identities,
ProjectObjectIdentity.created_at,
ProjectObjectIdentity.id,
label="identity attribution",
)
)
revisions = db.query(ProjectObjectRevision).filter(
ProjectObjectRevision.tenant_id == tenant_id,
ProjectObjectRevision.changed_by.in_(selectors.actor_ids),
)
revisions = _object_filter(revisions, ProjectObjectRevision, selectors)
records.extend(
_revision_actor_record(row)
for row in _limited(
revisions,
ProjectObjectRevision.recorded_at,
ProjectObjectRevision.id,
label="revision attribution",
)
)
events = db.query(ProjectObjectEvent).filter(
ProjectObjectEvent.tenant_id == tenant_id,
ProjectObjectEvent.actor_id.in_(selectors.actor_ids),
)
events = _object_filter(events, ProjectObjectEvent, selectors)
records.extend(
_event_actor_record(row)
for row in _limited(
events,
ProjectObjectEvent.occurred_at,
ProjectObjectEvent.id,
label="event attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Projects DSAR result limit exceeded; narrow selectors.")
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Projects DSAR subject selectors conflict.")
actions = []
for record in records:
_validate_record(record)
membership = record.resource_type == "project_membership"
actions.append(
DsarErasureActionRef(
action_id=(
f"projects:{'manual_review' if membership else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if membership else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if membership else "Retain ") + record.title,
rationale=(
"Membership removal must preserve access continuity, project "
"ownership, and retained decision history."
if membership
else record.retention_reason
or "Project lifecycle attribution remains governance evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Projects DSAR subject selectors conflict.")
results = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError("Projects DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Project membership remains unchanged pending access and "
"ownership review."
if action.kind == "manual_review"
else "Project lifecycle attribution remains governance evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("projects.account"),
references.get("access.account"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("projects.identity"),
references.get("identity.id"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("projects.membership"),
references.get("tenancy.membership"),
),
"function_assignment_id": _coalesce(
references.get("projects.function_assignment"),
references.get("projects.function_assignment_id"),
),
"project_id": _coalesce(
references.get("projects.project"), references.get("projects.project_id")
),
"portfolio_id": _coalesce(
references.get("projects.portfolio"),
references.get("projects.portfolio_id"),
),
"milestone_id": _coalesce(
references.get("projects.milestone"),
references.get("projects.milestone_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
object_values = tuple(
(kind, value)
for kind, value in (
("project", _optional(values["project_id"])),
("portfolio", _optional(values["portfolio_id"])),
("milestone", _optional(values["milestone_id"])),
)
if value
)
if len(object_values) > 1:
return None
account_id = _optional(values["account_id"])
identity_id = _optional(values["identity_id"])
membership_id = _optional(values["membership_id"])
function_assignment_id = _optional(values["function_assignment_id"])
actor_ids = tuple(
dict.fromkeys(
value for value in (account_id, identity_id, membership_id) if value
)
)
membership_subjects = tuple(
item
for item in (
("account", account_id) if account_id else None,
("identity", identity_id) if identity_id else None,
(
("function_assignment", function_assignment_id)
if function_assignment_id
else None
),
)
if item is not None
)
if not actor_ids and not membership_subjects:
return None
return _Selectors(
actor_ids=actor_ids,
membership_subjects=membership_subjects,
object_kind=object_values[0][0] if object_values else None,
object_id=object_values[0][1] if object_values else None,
)
def _object_filter(query, model, selectors: _Selectors):
if selectors.object_kind:
query = query.filter(model.object_kind == selectors.object_kind)
if selectors.object_id:
query = query.filter(model.object_id == selectors.object_id)
return query
def _membership_record(row: ProjectMembershipGrant) -> DsarRecordRef:
permissions = row.permissions
if not isinstance(permissions, list) or len(permissions) > _MAX_PERMISSIONS:
raise ValueError("Projects DSAR membership permissions exceed their bound.")
return DsarRecordRef(
provider_id="projects",
module_id="projects",
resource_type="project_membership",
resource_id=row.id,
category="project_participation",
title="Project membership",
data={
"membership_grant_id": row.id,
"object_kind": row.object_kind,
"object_id": row.object_id,
"subject_kind": row.subject_kind,
"subject_id": row.subject_id,
"role": row.role,
"permissions": [str(item)[:120] for item in permissions],
"active": row.active,
"source_revision": row.source_revision,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Membership removal requires project access, ownership, and history review."
),
)
def _identity_actor_record(row: ProjectObjectIdentity) -> DsarRecordRef:
return _attribution_record(
resource_type="project_identity_actor_attribution",
resource_id=row.id,
title="Project identity actor attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"activity": "created_project_identity",
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
)
def _revision_actor_record(row: ProjectObjectRevision) -> DsarRecordRef:
return _attribution_record(
resource_type="project_revision_actor_attribution",
resource_id=row.id,
title="Project revision actor attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"revision_id": row.id,
"revision": row.revision,
"state": row.state,
"visibility": row.visibility,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_project_revision",
},
observed_at=row.recorded_at,
)
def _event_actor_record(row: ProjectObjectEvent) -> DsarRecordRef:
return _attribution_record(
resource_type="project_event_actor_attribution",
resource_id=row.id,
title="Project event actor attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"object_revision": row.object_revision,
"event_id": row.event_id,
"event_type": row.event_type,
"occurred_at": _iso(row.occurred_at),
},
observed_at=row.occurred_at,
)
def _attribution_record(
*,
resource_type: str,
resource_id: str,
title: str,
data: dict[str, object],
observed_at: datetime | None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="projects",
module_id="projects",
resource_type=resource_type,
resource_id=resource_id,
category="project_governance_attribution",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Project lifecycle attribution is retained for governance and accountability."
),
)
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Projects DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Projects DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"project_membership",
"project_identity_actor_attribution",
"project_revision_actor_attribution",
"project_event_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "projects" or record.module_id != "projects":
raise ValueError("Projects DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Projects DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "projects" or action.module_id != "projects":
raise ValueError("Projects DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("projects:"):
raise ValueError("Projects DSAR action identity is invalid.")
__all__ = ["PROJECTS_DSAR_CAPABILITY", "ProjectsDsarProvider"]
+215 -26
View File
@@ -14,6 +14,7 @@ from govoplan_core.core.module_guards import (
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -24,6 +25,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
@@ -36,6 +38,10 @@ from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_projects.backend.acl import ProjectScopeAclProvider
from govoplan_projects.backend.db import models as project_models
from govoplan_projects.backend.dsar_provider import (
PROJECTS_DSAR_CAPABILITY,
ProjectsDsarProvider,
)
from govoplan_projects.backend.search_source import create_projects_search_source
from govoplan_projects.backend.service import (
CAPABILITY_PROJECTS_REGISTRY,
@@ -45,7 +51,7 @@ from govoplan_projects.backend.service import (
MODULE_ID = "projects"
MODULE_NAME = "Projects"
MODULE_VERSION = "0.1.16"
MODULE_VERSION = "0.1.20"
READ_SCOPE = "projects:project:read"
WRITE_SCOPE = "projects:project:write"
ADMIN_SCOPE = "projects:project:admin"
@@ -129,6 +135,10 @@ def _registry(context: ModuleContext) -> SqlProjectRegistry:
return SqlProjectRegistry()
def _dsar_provider(_context: ModuleContext) -> ProjectsDsarProvider:
return ProjectsDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
counts = {
kind: count
@@ -200,21 +210,25 @@ ROLE_TEMPLATES = (
DOCUMENTATION = (
DocumentationTopic(
id="projects.module-boundary",
title="Projects module boundary",
title="Plan and coordinate projects",
summary=(
"Portfolios, projects, versioned goals and outcomes, milestones, "
"dependencies, capacity, benefits, participants, status, and references."
"Coordinate portfolios, projects, milestones, participants, dependencies, "
"capacity assumptions, outcomes, and benefit reviews without absorbing work-item ownership."
),
body=(
"Projects owns native project context. Tasks and Tickets own "
"actionable work, Cases owns formal procedures, and Connectors "
"owns OpenProject synchronization. Reporting owns measured indicators; "
"Risk Compliance owns risks and controls; Projects links those facts to "
"planning, change impact, and benefit review."
"Documentation books sit immediately beside the visible heading or contextual label for "
"Projects, not among operational action buttons. Field help remains beside its label. "
"Projects owns native portfolio and project context, immutable revisions of goals and intended outcomes, "
"milestones, participants, status, dependencies, capacity assumptions, benefit reviews, and governed resource references. "
"Create or revise the planning context, review its impact, and link provider-owned work and evidence without copying it. "
"Tasks and Tickets retain actionable work, Cases retains formal procedures, and Connectors retains OpenProject transport. "
"Reporting owns measured indicators and Risk Compliance owns risks and controls; Projects links those facts to planning, "
"change impact, and benefit review."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
@@ -229,23 +243,108 @@ DOCUMENTATION = (
),
),
metadata={
"seed": True,
"domain_objects": [
"portfolio",
"project",
"milestone",
"versioned goal and intended outcome",
"dependency and capacity assumption",
"benefit review",
"project participant",
"project resource link",
"external project reference",
"kind": "workflow",
"help_contexts": [
"projects.workspace",
"projects.project",
"projects.milestone",
],
"first_slice": (
"Implement project and portfolio identity, status, milestones, "
"participants, outcome/benefit intent, dependency/resource links, "
"and OpenProject reference mapping."
"purpose": (
"Maintain a revisioned planning context and connect provider-owned work, evidence, and measurements to it."
),
"prerequisites": [
"The actor can read the project; revisions require project write access.",
"Referenced work, cases, files, risks, reports, and external systems remain authorized by their owner providers.",
],
"steps": [
"Select a portfolio or create the project identity, status, participants, and planning horizon.",
"Record versioned goals, intended outcomes, milestones, dependencies, and capacity assumptions.",
"Link Tasks, Tickets, Cases, files, risks, controls, reports, or external project references without copying owner data.",
"Review status, change impact, milestone progress, and benefit evidence before appending a revision.",
"Use owner-module links to manage actionable work or formal procedures in their authoritative surface.",
],
"fields": {
"portfolio": "Groups projects for planning and oversight without changing project authority.",
"project": "The native revisioned context for outcome, status, participation, and resource links.",
"milestone": "A dated project checkpoint, not an actionable Task or formal Case state.",
"participant": "An explicit project access grant and role, independent of object ownership elsewhere.",
"external_reference": "A governed link to a provider-owned project or resource, including OpenProject mappings.",
},
"limitations": [
"Projects does not own Tasks, Tickets, Cases, source measurements, risks, controls, files, or external synchronization transport.",
"A linked object remains unavailable when its owner provider denies current access.",
],
"operational_consequences": {
"revise_plan": "Appends a new planning revision while preserving prior goals, status, and evidence links.",
"change_participation": "Changes project access only and does not grant access to linked provider-owned objects.",
"link_external_resource": "Stores a governed reference; synchronization remains a Connector responsibility.",
},
"verification": [
"The project revision names its portfolio, status, participants, milestones, and intended outcomes.",
"Every linked resource identifies its owner module or external provider and remains independently authorized.",
"Benefit and change-impact review preserves the revision and evidence references used for the decision.",
],
},
translations={
"de": {
"title": "Projekte planen und koordinieren",
"summary": (
"Portfolios, Projekte, Meilensteine, Beteiligte, Abhängigkeiten, Kapazitätsannahmen, "
"Ergebnisse und Nutzenprüfungen koordinieren, ohne die Eigentümerschaft an Arbeitselementen zu übernehmen."
),
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Projekte, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe "
"bleibt neben der Feldbezeichnung. "
"Projects führt den nativen Portfolio- und Projektkontext, unveränderliche Revisionen von Zielen und "
"beabsichtigten Ergebnissen, Meilensteine, Beteiligte, Status, Abhängigkeiten, Kapazitätsannahmen, "
"Nutzenprüfungen und gesteuerte Ressourcenreferenzen. Planungskontext wird angelegt oder revidiert, "
"seine Auswirkung geprüft und anbietergeführte Arbeit und Nachweise werden verknüpft, ohne sie zu kopieren. "
"Tasks und Tickets behalten ausführbare Arbeit, Cases formelle Verfahren und Connectors den OpenProject-Transport. "
"Reporting führt gemessene Kennzahlen und Risk Compliance Risiken und Kontrollen; Projects verknüpft diese Fakten "
"mit Planung, Änderungsfolgen und Nutzenprüfung."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"purpose": (
"Einen revisionierten Planungskontext pflegen und anbietergeführte Arbeit, Nachweise und Messwerte damit verknüpfen."
),
"prerequisites": [
"Die handelnde Person darf das Projekt lesen; Revisionen erfordern Projektschreibberechtigung.",
"Verknüpfte Arbeit, Verfahren, Dateien, Risiken, Berichte und externe Systeme bleiben durch ihre Eigentümeranbieter autorisiert.",
],
"steps": [
"Ein Portfolio auswählen oder Projektidentität, Status, Beteiligte und Planungshorizont anlegen.",
"Revisionierte Ziele, beabsichtigte Ergebnisse, Meilensteine, Abhängigkeiten und Kapazitätsannahmen erfassen.",
"Tasks, Tickets, Cases, Dateien, Risiken, Kontrollen, Berichte oder externe Projektreferenzen verknüpfen, ohne Eigentümerdaten zu kopieren.",
"Status, Änderungsfolgen, Meilensteinfortschritt und Nutzennachweise prüfen, bevor eine Revision angefügt wird.",
"Verknüpfungen zu Eigentümermodulen verwenden, um ausführbare Arbeit oder formelle Verfahren in deren führender Oberfläche zu verwalten.",
],
"fields": {
"portfolio": "Gruppiert Projekte für Planung und Aufsicht, ohne die Projektzuständigkeit zu verändern.",
"project": "Der native revisionierte Kontext für Ergebnis, Status, Beteiligung und Ressourcenverknüpfungen.",
"milestone": "Ein datierter Projektprüfpunkt, kein ausführbarer Task und kein formeller Case-Status.",
"participant": "Eine ausdrückliche Projektzugriffsfreigabe und Rolle, unabhängig von Eigentümerschaft in anderen Modulen.",
"external_reference": "Eine gesteuerte Verknüpfung zu einem anbietergeführten Projekt oder einer Ressource, einschließlich OpenProject-Zuordnungen.",
},
"limitations": [
"Projects führt weder Tasks, Tickets, Cases, Quellmesswerte, Risiken, Kontrollen, Dateien noch externen Synchronisationstransport.",
"Ein verknüpftes Objekt bleibt unverfügbar, wenn sein Eigentümeranbieter den aktuellen Zugriff verweigert.",
],
"operational_consequences": {
"revise_plan": "Fügt eine neue Planungsrevision an und bewahrt frühere Ziele, Status und Nachweisverknüpfungen.",
"change_participation": "Ändert nur den Projektzugriff und gewährt keinen Zugriff auf verknüpfte anbietergeführte Objekte.",
"link_external_resource": "Speichert eine gesteuerte Referenz; Synchronisation bleibt Aufgabe eines Connectors.",
},
"verification": [
"Die Projektrevision nennt Portfolio, Status, Beteiligte, Meilensteine und beabsichtigte Ergebnisse.",
"Jede verknüpfte Ressource nennt Eigentümermodul oder externen Anbieter und bleibt unabhängig autorisiert.",
"Nutzen- und Änderungsfolgenprüfung bewahrt die für die Entscheidung verwendete Revision und Nachweisreferenzen.",
],
}
},
),
)
@@ -292,6 +391,17 @@ manifest = ModuleManifest(
order=36,
),
),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("projects.nav.projects", "projects.route.projects"),
order=10,
),
),
view_surfaces=(
ViewSurface(
id="projects.navigation",
@@ -327,8 +437,12 @@ manifest = ModuleManifest(
),
provides_interfaces=(
ModuleInterfaceProvider(name="projects.registry", version="0.1.0"),
ModuleInterfaceProvider(name=PROJECTS_DSAR_CAPABILITY, version="0.1.0"),
),
capability_factories={CAPABILITY_PROJECTS_REGISTRY: _registry},
capability_factories={
CAPABILITY_PROJECTS_REGISTRY: _registry,
PROJECTS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation(
label="Projects registry",
@@ -338,6 +452,14 @@ manifest = ModuleManifest(
),
contract_version="0.1.0",
),
PROJECTS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Projects data-subject request provider",
summary=(
"Exports exact project memberships and minimized lifecycle attribution "
"without project payload content."
),
contract_version="0.1.0",
),
},
search_sources=(
SearchSourceProviderRegistration(
@@ -377,7 +499,74 @@ manifest = ModuleManifest(
ProjectScopeAclProvider("milestone"),
),
tenant_summary_providers=(_tenant_summary,),
documentation=DOCUMENTATION,
documentation=(
DocumentationTopic(
id="projects.data-subject-requests",
title="Project data-subject requests",
summary=(
"Export project participation and accountable activity without project content."
),
body=(
"Projects correlates exact account and identity membership grants, plus "
"an explicitly supplied function-assignment reference. It separately "
"matches exact account, identity, and membership identifiers used for "
"creation and lifecycle attribution. Searches can narrow to one project, "
"portfolio, or milestone, but an object identifier alone never establishes "
"a subject. Membership exports include role, permissions, active state, "
"and source revision. Titles, search text, planning payloads, event payloads, "
"request hashes, idempotency keys, and unrelated members are excluded. "
"Membership removal requires manual access and ownership review; immutable "
"creation, revision, and event attribution remains retained governance evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "access", "tasks", "audit"),
order=90,
metadata={
"kind": "reference",
"help_contexts": [
"projects.workspace",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_project_membership": "Returns exact subject grants without unrelated members.",
"review_membership_removal": "Requires ownership and access-continuity review.",
"retain_project_attribution": "Preserves immutable lifecycle accountability.",
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Projekten",
"summary": (
"Projektbeteiligung und verantwortbare Aktivitäten ohne Projektinhalte exportieren."
),
"body": (
"Projects gleicht exakte Konto- und Identitätsfreigaben sowie eine ausdrücklich angegebene "
"Funktionszuweisungsreferenz ab. Exakte Konto-, Identitäts- und Mitgliedschaftskennungen für "
"Anlage- und Lebenszykluszuschreibung werden getrennt ermittelt. Suchen können auf ein Projekt, "
"Portfolio oder einen Meilenstein eingegrenzt werden; eine Objektkennung allein begründet niemals "
"eine betroffene Person. Beteiligungsexporte enthalten Rolle, Berechtigungen, Aktivstatus und "
"Quellrevision. Titel, Suchtext, Planungs- und Ereignisinhalte, Anforderungsprüfsummen, "
"Idempotenzschlüssel und unbeteiligte Mitglieder bleiben ausgeschlossen. Die Entfernung einer "
"Beteiligung erfordert eine manuelle Prüfung von Zugriff und Eigentumsfortbestand; unveränderliche "
"Zuschreibungen zu Anlage, Revision und Ereignissen bleiben als Steuerungsnachweise erhalten."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_project_membership": "Gibt exakte Betroffenenfreigaben ohne unbeteiligte Mitglieder zurück.",
"review_membership_removal": "Erfordert eine Prüfung von Eigentümerschaft und Zugriffskontinuität.",
"retain_project_attribution": "Bewahrt unveränderliche Lebenszyklusverantwortung auf.",
}
}
},
),
*DOCUMENTATION,
),
architecture=ARCHITECTURE,
)
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
user_workflow_scope_condition_issues,
)
from govoplan_projects.backend.manifest import manifest
class ProjectsDocumentationTests(unittest.TestCase):
def test_public_topics_have_complete_german_reference_content(self) -> None:
self.assertEqual(2, len(manifest.documentation))
for topic in manifest.documentation:
translation = topic.translations.get("de", {})
self.assertTrue(
all(translation.get(key) for key in ("title", "summary", "body"))
)
self.assertEqual((), documentation_structured_translation_issues(topic))
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
for topic in manifest.documentation:
self.assertEqual((), user_workflow_scope_condition_issues(topic))
if __name__ == "__main__":
unittest.main()
+213
View File
@@ -0,0 +1,213 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_projects.backend.db.models import (
ProjectMembershipGrant,
ProjectObjectEvent,
ProjectObjectIdentity,
ProjectObjectRevision,
)
from govoplan_projects.backend.dsar_provider import (
PROJECTS_DSAR_CAPABILITY,
ProjectsDsarProvider,
)
from govoplan_projects.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 15, 0, tzinfo=UTC)
class ProjectsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = ProjectsDsarProvider()
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add_all(
(
ProjectObjectIdentity(
id="identity-row-1",
tenant_id="tenant-1",
object_kind="project",
object_id="project-1",
object_key="secret-object-key-do-not-export",
created_by="account-1",
created_at=NOW,
updated_at=NOW,
),
ProjectObjectRevision(
id="revision-1",
tenant_id="tenant-1",
identity_id="identity-row-1",
object_kind="project",
object_id="project-1",
revision=2,
state="active",
title="Sensitive project title do not export",
visibility="restricted",
recorded_at=NOW,
search_text="project-search-content-do-not-export",
payload={"secret": "project-payload-do-not-export"},
changed_by="account-1",
created_at=NOW,
updated_at=NOW,
),
ProjectMembershipGrant(
id="membership-1",
tenant_id="tenant-1",
object_kind="project",
object_id="project-1",
subject_kind="account",
subject_id="account-1",
role="member",
permissions=["read", "write"],
active=True,
source_revision=2,
created_at=NOW,
updated_at=NOW,
),
ProjectMembershipGrant(
id="membership-other-person",
tenant_id="tenant-1",
object_kind="project",
object_id="project-1",
subject_kind="account",
subject_id="account-other-do-not-export",
role="owner",
permissions=["admin"],
active=True,
source_revision=2,
),
ProjectObjectEvent(
id="event-row-1",
tenant_id="tenant-1",
object_kind="project",
object_id="project-1",
object_revision=2,
event_id="event-1",
event_type="projects.project.updated",
occurred_at=NOW,
actor_id="account-1",
idempotency_key="event-idempotency-do-not-export",
request_sha256="event-request-hash-do-not-export",
payload={"secret": "event-payload-do-not-export"},
created_at=NOW,
updated_at=NOW,
),
ProjectMembershipGrant(
id="membership-other-tenant",
tenant_id="tenant-2",
object_kind="project",
object_id="project-other",
subject_kind="account",
subject_id="account-1",
role="member",
permissions=["read"],
active=True,
source_revision=1,
),
)
)
def test_search_exports_membership_and_minimized_attribution(self) -> None:
self.assertIsInstance(self.provider, DsarProvider)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(
{
"project_membership",
"project_identity_actor_attribution",
"project_revision_actor_attribution",
"project_event_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("membership-1", exported)
for excluded in (
"account-other-do-not-export",
"secret-object-key-do-not-export",
"Sensitive project title do not export",
"project-search-content-do-not-export",
"project-payload-do-not-export",
"event-idempotency-do-not-export",
"event-request-hash-do-not-export",
"event-payload-do-not-export",
):
self.assertNotIn(excluded, exported)
def test_requires_subject_identity_and_enforces_narrowing(self) -> None:
self.assertEqual(
(),
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="member@example.test"),
),
)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"projects.project": "project-1"},
),
)
self.assertEqual(4, len(narrowed))
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={
"projects.project": "project-1",
"projects.portfolio": "portfolio-1",
},
),
)
self.assertEqual((), conflict)
def test_membership_requires_review_and_attribution_is_retained(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
by_type = {action.resource_type: action.kind for action in actions}
self.assertEqual("manual_review", by_type["project_membership"])
self.assertEqual("retain", by_type["project_event_actor_attribution"])
def test_manifest_registers_provider_and_documentation(self) -> None:
self.assertIn(PROJECTS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"projects.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/projects-webui",
"version": "0.1.16",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -17,7 +17,7 @@
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+42 -33
View File
@@ -13,17 +13,24 @@ import {
useState,
type FormEvent
} from "react";
import {
Button,
import { Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
FilterBar,
FormLayout,
IconButton,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
hasScope,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -205,9 +212,15 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
return (
<main className="projects-page">
<div className="projects-shell">
<div className="projects-toolbar">
<form className="projects-search" onSubmit={submitSearch}>
<WorkspaceFrame className="projects-shell" label="Projects workspace" interfaceId="projects.workspace" helpContextId="projects.page.workspace" helpModuleId="projects">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading }}
className="projects-toolbar"
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="projects-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
value={query}
@@ -216,7 +229,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
placeholder="Search portfolios, projects, and milestones"
/>
<Button type="submit" variant="primary">Search</Button>
</form>
</FilterBar>
<label className="projects-kind-filter">
<span>Type</span>
<select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}>
@@ -227,16 +240,19 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
</select>
</label>
<span className="projects-count">{total} objects</span>
<DocumentationHelpLink
</>}
title="Projects"
titleLevel={1}
titleHelp={<DocumentationHelpLink
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
label="Open Projects documentation"
/>
{canWrite &&
/>}
createAction={canWrite ?
<Button type="button" variant="primary" onClick={openCreate}>
<Plus size={16} aria-hidden="true" /> New
</Button>
}
</div>
: undefined}
/>
{error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
{error}
@@ -246,35 +262,28 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
<PageScrollViewport className="projects-list-viewport">
{loading && <LoadingIndicator label="Loading projects" />}
{!loading && objects.length === 0 &&
<div className="projects-empty">No matching planning objects.</div>
<StatePanel size="compact" description="No matching planning objects." />
}
<div className="projects-list" role="list">
<SelectionList variant="navigation" label="Planning objects">
{objects.map((item) =>
<button
type="button"
role="listitem"
<SelectionListItem
key={objectKey(item)}
className={`project-row${objectKey(item) === selectedKey ? " is-selected" : ""}`}
selected={objectKey(item) === selectedKey}
onClick={() => setSelectedKey(objectKey(item))}>
<span className="project-row-icon">{kindIcon(item.object_kind)}</span>
<span className="project-row-main">
<strong>{item.title}</strong>
<small>{item.object_key}</small>
</span>
<SelectionListItemContent leading={kindIcon(item.object_kind)} title={item.title} description={`${item.object_key} · ${formatDate(item.due_at)}`} />
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
<span className="project-row-date">{formatDate(item.due_at)}</span>
</button>
</SelectionListItem>
)}
</div>
</SelectionList>
</PageScrollViewport>
<PageScrollViewport className="project-detail-viewport">
{selected ?
<ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> :
<div className="projects-empty">Select a portfolio, project, or milestone.</div>
<StatePanel size="fill" title="Planning objects" description="Select a portfolio, project, or milestone." />
}
</PageScrollViewport>
</div>
</div>
</WorkspaceFrame>
<ProjectEditorDialog
open={editorOpen}
record={editing}
@@ -302,7 +311,7 @@ function ProjectDetail({ record, canWrite, onEdit }: {
<header className="project-detail-header">
<div>
<span className="project-eyebrow">{humanize(record.object_kind)} · {record.object_key}</span>
<h1>{record.title}</h1>
<h2>{record.title}</h2>
</div>
<div className="project-detail-actions">
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
@@ -397,7 +406,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
</>
}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
<FormLayout id="project-editor-form" columns={2} gap="compact" collapseAt="narrow" className="project-editor-form" onSubmit={submit}>
<label>
<FieldLabel>Type</FieldLabel>
<select
@@ -413,7 +422,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
</select>
</label>
{values.kind !== "portfolio" &&
<label className="project-editor-wide">
<label className="wide">
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
<select
value={values.parentRef}
@@ -437,7 +446,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
<FieldLabel help="Stable identifier used in links and external mappings.">Key</FieldLabel>
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
</label>
<label className="project-editor-wide">
<label className="wide">
<FieldLabel>Title</FieldLabel>
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
</label>
@@ -462,7 +471,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
<FieldLabel>Due</FieldLabel>
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
</label>
<label className="project-editor-wide">
<label className="wide">
<FieldLabel>Description</FieldLabel>
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
</label>
@@ -470,7 +479,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
<FieldLabel help="Recorded with the immutable project revision and lifecycle evidence.">Change reason</FieldLabel>
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
</label>
</form>
</FormLayout>
</Dialog>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
/** Module-owned translations for contextual headings. */
export const generatedTranslations: PlatformTranslations = {
en: {
"Projects": "Projects",
},
de: {
"Projects": "Projekte",
},
};
+2
View File
@@ -1,11 +1,13 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/projects.css";
const ProjectsPage = lazy(() => import("./features/projects/ProjectsPage"));
export const projectsModule: PlatformWebModule = {
translations: generatedTranslations,
id: "projects",
label: "Projects",
version: "0.1.14",
+8 -131
View File
@@ -1,36 +1,16 @@
.projects-page,
.projects-shell {
.projects-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.projects-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.projects-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
.projects-page :where(button, input, select, textarea, a[href]):focus-visible {
outline: var(--focus-outline);
outline-offset: 2px;
}
.projects-search {
display: flex;
align-items: center;
gap: 8px;
width: min(560px, 48vw);
}
.projects-search input {
min-width: 160px;
flex: 1;
flex: 1 1 560px;
}
.projects-kind-filter {
@@ -67,69 +47,6 @@
background: var(--surface-subtle, var(--surface));
}
.projects-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.project-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto minmax(96px, auto);
align-items: center;
gap: 10px;
width: 100%;
min-height: 62px;
padding: 9px 12px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.project-row:last-child {
border-bottom: 0;
}
.project-row:hover,
.project-row:focus-visible,
.project-row.is-selected {
background: var(--hover-bg);
}
.project-row.is-selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.project-row-icon {
display: grid;
color: var(--text-soft);
place-items: center;
}
.project-row-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.project-row-main strong,
.project-row-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-row-main small,
.project-row-date {
color: var(--text-soft);
font-size: 0.78rem;
}
.project-detail {
width: min(100%, 980px);
margin: 0 auto;
@@ -145,7 +62,7 @@
border-bottom: 1px solid var(--border);
}
.project-detail-header h1 {
.project-detail-header h2 {
margin: 4px 0 0;
font-size: 1.4rem;
letter-spacing: 0;
@@ -175,7 +92,7 @@
gap: 1px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--border);
}
@@ -228,22 +145,10 @@
font-size: 0.82rem;
}
.projects-empty {
padding: 36px 10px;
color: var(--text-soft);
text-align: center;
}
.project-editor-dialog {
width: min(720px, calc(100vw - 32px));
}
.project-editor-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-editor-form label {
display: flex;
min-width: 0;
@@ -256,20 +161,7 @@
font-size: 0.8rem;
}
.project-editor-wide {
grid-column: 1 / -1;
}
@media (max-width: 920px) {
.projects-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.projects-search {
width: 100%;
}
@media (max-width: 900px) {
.projects-count {
margin-left: 0;
}
@@ -291,19 +183,4 @@
}
@media (max-width: 560px) {
.project-row {
grid-template-columns: 26px minmax(0, 1fr) auto;
}
.project-row-date {
display: none;
}
.project-editor-form {
grid-template-columns: 1fr;
}
.project-editor-wide {
grid-column: auto;
}
}