Compare commits
6
Commits
8bfcf2d6b4
...
v0.1.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fade1b173 | ||
|
|
260f87a6a6 | ||
|
|
20b8aa080c | ||
|
|
4e7e972415 | ||
|
|
2f53dc44dc | ||
|
|
87e05af025 |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -22,6 +22,8 @@ Connectors; Projects stores only the native planning object and canonical
|
|||||||
external reference.
|
external reference.
|
||||||
|
|
||||||
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md).
|
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md).
|
||||||
|
The list-detail, editor, state, consequence, and accessibility mapping is in
|
||||||
|
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Projects Interface Pattern Migration
|
||||||
|
|
||||||
|
Projects uses a full-height list-detail workspace. It owns native portfolio,
|
||||||
|
project, and milestone context; Tasks and Tickets own actionable work,
|
||||||
|
Connectors owns OpenProject transport, and optional integrations remain
|
||||||
|
capability-based.
|
||||||
|
|
||||||
|
| Surface | Task and archetype | Consequence and state contract |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/projects` catalogue | Search/filter and select a planning object | Loading, empty, failed, filtered, and selected states retain the list context. Restricted records are removed by backend authorization rather than cosmetically hidden. |
|
||||||
|
| Object detail | Inspect status, dates, ownership/membership evidence, outcomes, benefits, dependencies, and links | The selected identity, key, state, and revision remain visible while detail changes. |
|
||||||
|
| Create/edit dialog | Adaptive create/edit | Core Dialog supplies focus containment and return. Stable key, state, visibility, parent, dates, and change reason have labelled controls; save errors remain attached to the dialog. |
|
||||||
|
| Revisioned save | Consequential corrective action | Every successful save creates immutable revision and lifecycle evidence under optimistic concurrency. The required change reason explains the new record. |
|
||||||
|
|
||||||
|
Creating and editing require the Projects write permission. Restricted
|
||||||
|
visibility changes who may read an object, so its consequence is explained at
|
||||||
|
the field and enforced by the backend ACL. The workspace reflows to list then
|
||||||
|
detail at narrow widths, preserves semantic button/list behavior, and uses Core
|
||||||
|
buttons, icon buttons, dialog, alerts, loading, status, field labels, scrolling,
|
||||||
|
and documentation help.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `npm run test:interface-pattern`
|
||||||
|
- Projects service, migration, and manifest tests
|
||||||
|
- the Core TypeScript graph, structural localization audit, theme check, module
|
||||||
|
permutations, and full-product bundle budget
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/projects",
|
"name": "@govoplan/projects",
|
||||||
"version": "0.1.14",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Projects domain module.",
|
"description": "GovOPlaN Projects domain module.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-projects"
|
name = "govoplan-projects"
|
||||||
version = "0.1.14"
|
version = "0.1.15"
|
||||||
description = "GovOPlaN Projects domain module."
|
description = "GovOPlaN Projects domain module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.14",
|
"govoplan-core>=0.1.15",
|
||||||
"govoplan-access>=0.1.14",
|
"govoplan-access>=0.1.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ from govoplan_projects.backend.service import (
|
|||||||
|
|
||||||
MODULE_ID = "projects"
|
MODULE_ID = "projects"
|
||||||
MODULE_NAME = "Projects"
|
MODULE_NAME = "Projects"
|
||||||
MODULE_VERSION = "0.1.14"
|
MODULE_VERSION = "0.1.15"
|
||||||
READ_SCOPE = "projects:project:read"
|
READ_SCOPE = "projects:project:read"
|
||||||
WRITE_SCOPE = "projects:project:write"
|
WRITE_SCOPE = "projects:project:write"
|
||||||
ADMIN_SCOPE = "projects:project:admin"
|
ADMIN_SCOPE = "projects:project:admin"
|
||||||
@@ -222,6 +222,11 @@ DOCUMENTATION = (
|
|||||||
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
|
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Projects interface pattern audit",
|
||||||
|
href="govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/projects-webui",
|
"name": "@govoplan/projects-webui",
|
||||||
"version": "0.1.14",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,8 +13,11 @@
|
|||||||
},
|
},
|
||||||
"./styles/projects.css": "./src/styles/projects.css"
|
"./styles/projects.css": "./src/styles/projects.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.15",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const page = fs.readFileSync("src/features/projects/ProjectsPage.tsx", "utf8");
|
||||||
|
const styles = fs.readFileSync("src/styles/projects.css", "utf8");
|
||||||
|
|
||||||
|
assert.ok(page.includes("DocumentationHelpLink"), "Projects exposes configured-system help");
|
||||||
|
assert.ok(page.includes("PageScrollViewport"), "Projects owns bounded list and detail scrolling");
|
||||||
|
assert.ok(page.includes("<Dialog"), "Projects uses the shared focus-contained editor dialog");
|
||||||
|
assert.ok(page.includes("FieldLabel"), "Project editor fields use the shared label/help contract");
|
||||||
|
assert.ok(page.includes('DismissibleAlert tone="danger" resetKey={error}'), "Save failures remain attached to the editor");
|
||||||
|
assert.ok(page.includes("StatusBadge"), "Project lifecycle state is not conveyed by color alone");
|
||||||
|
assert.ok(!page.includes("window.alert("), "Projects must not use browser alerts");
|
||||||
|
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Projects uses semantic interactive elements");
|
||||||
|
assert.ok(styles.includes("@media (max-width: 560px)"), "Projects retains a narrow-viewport editor layout");
|
||||||
|
assert.ok(styles.includes(":focus-visible"), "Projects retains visible keyboard focus");
|
||||||
|
|
||||||
|
console.log("Projects interface pattern contract passed.");
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FieldLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
@@ -69,6 +71,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [editorError, setEditorError] = useState("");
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<ProjectRecord | null>(null);
|
const [editing, setEditing] = useState<ProjectRecord | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -123,12 +126,14 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setEditorError("");
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit() {
|
function openEdit() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setEditing(selected);
|
setEditing(selected);
|
||||||
|
setEditorError("");
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,10 +193,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
}
|
}
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setEditorError("");
|
||||||
await reload();
|
await reload();
|
||||||
setSelectedKey(objectKey(saved));
|
setSelectedKey(objectKey(saved));
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : "The project object could not be saved.");
|
setEditorError(reason instanceof Error ? reason.message : "The project object could not be saved.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -221,6 +227,10 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span className="projects-count">{total} objects</span>
|
<span className="projects-count">{total} objects</span>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
|
||||||
|
label="Open Projects documentation"
|
||||||
|
/>
|
||||||
{canWrite &&
|
{canWrite &&
|
||||||
<Button type="button" variant="primary" onClick={openCreate}>
|
<Button type="button" variant="primary" onClick={openCreate}>
|
||||||
<Plus size={16} aria-hidden="true" /> New
|
<Plus size={16} aria-hidden="true" /> New
|
||||||
@@ -270,7 +280,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
record={editing}
|
record={editing}
|
||||||
objects={parentOptions}
|
objects={parentOptions}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onClose={() => setEditorOpen(false)}
|
error={editorError}
|
||||||
|
onClose={() => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditorError("");
|
||||||
|
}}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@@ -337,11 +351,12 @@ function PlanningStat({ label, value }: { label: string; value: number }) {
|
|||||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: {
|
function ProjectEditorDialog({ open, record, objects, saving, error, onClose, onSave }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
record: ProjectRecord | null;
|
record: ProjectRecord | null;
|
||||||
objects: ProjectRecord[];
|
objects: ProjectRecord[];
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
error: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (values: EditorValues) => Promise<void>;
|
onSave: (values: EditorValues) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
@@ -381,9 +396,10 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
|
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
|
||||||
<label>
|
<label>
|
||||||
<span>Type</span>
|
<FieldLabel>Type</FieldLabel>
|
||||||
<select
|
<select
|
||||||
value={values.kind}
|
value={values.kind}
|
||||||
disabled={Boolean(record)}
|
disabled={Boolean(record)}
|
||||||
@@ -398,7 +414,7 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
|
|||||||
</label>
|
</label>
|
||||||
{values.kind !== "portfolio" &&
|
{values.kind !== "portfolio" &&
|
||||||
<label className="project-editor-wide">
|
<label className="project-editor-wide">
|
||||||
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span>
|
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
|
||||||
<select
|
<select
|
||||||
value={values.parentRef}
|
value={values.parentRef}
|
||||||
required={values.kind === "milestone"}
|
required={values.kind === "milestone"}
|
||||||
@@ -418,40 +434,40 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
|
|||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
<label>
|
<label>
|
||||||
<span>Key</span>
|
<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)} />
|
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="project-editor-wide">
|
<label className="project-editor-wide">
|
||||||
<span>Title</span>
|
<FieldLabel>Title</FieldLabel>
|
||||||
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
|
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>State</span>
|
<FieldLabel>State</FieldLabel>
|
||||||
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
|
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
|
||||||
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Visibility</span>
|
<FieldLabel help="Restricted objects require an owner or explicit membership grant.">Visibility</FieldLabel>
|
||||||
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
|
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
|
||||||
<option value="tenant">Tenant</option>
|
<option value="tenant">Tenant</option>
|
||||||
<option value="restricted">Restricted</option>
|
<option value="restricted">Restricted</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Starts</span>
|
<FieldLabel>Starts</FieldLabel>
|
||||||
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
|
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Due</span>
|
<FieldLabel>Due</FieldLabel>
|
||||||
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
|
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="project-editor-wide">
|
<label className="project-editor-wide">
|
||||||
<span>Description</span>
|
<FieldLabel>Description</FieldLabel>
|
||||||
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
|
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="project-editor-wide">
|
<label className="project-editor-wide">
|
||||||
<span>Change reason</span>
|
<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)} />
|
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user