Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c2147134 | ||
|
|
17a808a3ba | ||
|
|
efa59173e4 |
@@ -19,6 +19,10 @@ Views are presentation filters, never security boundaries. Hidden routes still
|
||||
use their normal permission checks. Required Views retain the View selector and
|
||||
administration surfaces needed to inspect and change the assignment, preventing
|
||||
administrators from locking an installation out of its own configuration.
|
||||
The selector is the titlebar eye button next to the language control. It opens
|
||||
the available-View menu and uses the accent color whenever a specialized View
|
||||
is active; the full interface keeps the icon neutral. The selector announces
|
||||
the `views.selector` help context so `F1` opens the matching Views guidance.
|
||||
|
||||
Views are also the canonical user/group module-visibility mechanism. Core
|
||||
creates a root `<module>.module` surface for every WebUI module, so a View may
|
||||
|
||||
@@ -9,7 +9,7 @@ assignments, effective selection, and the safeguards described here.
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `views.selector` | Compact global selector | Change active presentation projection | Localized accessible selector, required-state explanation, unsaved-change guard |
|
||||
| `views.selector` | Compact titlebar eye menu | Change active presentation projection | Neutral full-interface state, accented specialized state, localized accessible menu, required-state explanation, unsaved-change guard |
|
||||
| `views.admin.system` | Administration list-detail editor | Create, publish, archive, assign | Shared admin layout, explicit disabled reasons, required-View lockout validation, contextual help |
|
||||
| `views.admin.tenant` | Administration list-detail editor | Create, publish, archive, assign | Inheritance provenance, searchable group/user targets, explicit optional-module blocker |
|
||||
| `views.settings.personal` | Settings list-detail editor | Create and publish owned Views | Shared owner selector, permission blocker, contextual field help |
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-views"
|
||||
version = "0.1.15"
|
||||
version = "0.1.17"
|
||||
description = "Governed task-focused interface projections for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.15"]
|
||||
dependencies = ["govoplan-core>=0.1.17"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Views module."""
|
||||
|
||||
__version__ = "0.1.15"
|
||||
__version__ = "0.1.17"
|
||||
|
||||
@@ -29,7 +29,7 @@ from govoplan_views.backend.db import models as view_models
|
||||
|
||||
MODULE_ID = "views"
|
||||
MODULE_NAME = "Views"
|
||||
MODULE_VERSION = "0.1.15"
|
||||
MODULE_VERSION = "0.1.17"
|
||||
|
||||
DEFINITION_READ_SCOPE = "views:definition:read"
|
||||
DEFINITION_WRITE_SCOPE = "views:definition:write"
|
||||
@@ -303,8 +303,10 @@ manifest = ModuleManifest(
|
||||
"and tenant administrators can publish Views and make them "
|
||||
"available, default, or required at system, tenant, group, and "
|
||||
"user scope. Required Views retain administration escape surfaces "
|
||||
"so they can always be inspected and changed. Hidden functions "
|
||||
"remain protected by their normal permission checks."
|
||||
"so they can always be inspected and changed. The titlebar eye "
|
||||
"opens the selector and is accented while a specialized View is "
|
||||
"active. Hidden functions remain protected by their normal "
|
||||
"permission checks."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"./styles/views.css": "./src/styles/views.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -24,6 +24,8 @@ assert(admin.includes("LOCKOUT_SURFACES") && admin.includes("requiredMissing"),
|
||||
assert(patterns.includes('topicId: "views.interface-projections"') && patterns.includes('topicId: "views.reference.fields-and-consequences"'), "Views uses stable manifest-backed help references");
|
||||
assert(moduleSource.includes("generatedTranslations") && moduleSource.includes('label: "i18n:govoplan-views.views"'), "Module and surface labels are localized");
|
||||
assert(selector.includes("usePlatformLanguage") && selector.includes("required_by_administrator"), "The global selector localizes and explains its locked state");
|
||||
assert(selector.includes("View as ViewIcon") && selector.includes("is-context-active"), "The global selector uses the compact View icon and marks an effective projection");
|
||||
assert(selector.includes("useUnsavedChanges") && selector.includes("requestNavigation"), "View changes retain the shared dirty-navigation guard");
|
||||
assert(translations.includes('"i18n:govoplan-views.views_administration_is_read_only"'), "Availability explanations are present in the translation catalogue");
|
||||
|
||||
console.log("Views surfaces satisfy the recorded interface pattern-language contract.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Eye, LockKeyhole } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Check, LockKeyhole, View as ViewIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DismissibleAlert,
|
||||
dispatchPlatformViewChanged,
|
||||
usePlatformLanguage,
|
||||
useUnsavedChanges,
|
||||
@@ -15,10 +16,21 @@ export default function ViewSelector({
|
||||
}: ViewSelectorProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const options = projection?.availableViews ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
|
||||
}
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
if (!projection?.activeViewId && options.length === 0) return null;
|
||||
|
||||
async function performSelect(viewId: string) {
|
||||
@@ -27,6 +39,7 @@ export default function ViewSelector({
|
||||
try {
|
||||
await selectEffectiveView(settings, viewId || null);
|
||||
dispatchPlatformViewChanged();
|
||||
setOpen(false);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error
|
||||
@@ -45,6 +58,8 @@ export default function ViewSelector({
|
||||
}
|
||||
|
||||
const locked = Boolean(projection?.locked);
|
||||
const activeViewId = projection?.activeViewId ?? "";
|
||||
const specialized = projection?.projectionActive ?? Boolean(activeViewId);
|
||||
const diagnostic = projection?.diagnostics.find((item) => item.severity === "error")
|
||||
?? projection?.diagnostics[0];
|
||||
const title = translateText(error || diagnostic?.message || (
|
||||
@@ -54,27 +69,75 @@ export default function ViewSelector({
|
||||
));
|
||||
|
||||
return (
|
||||
<label className="titlebar-view-selector" title={title}>
|
||||
{locked
|
||||
? <LockKeyhole size={16} aria-hidden="true" />
|
||||
: <Eye size={16} aria-hidden="true" />}
|
||||
<span className="sr-only">i18n:govoplan-views.current_view</span>
|
||||
<select
|
||||
value={projection?.activeViewId ?? ""}
|
||||
disabled={busy || locked}
|
||||
<div className="context-menu-wrap titlebar-view-menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`titlebar-icon-link${specialized ? " is-context-active" : ""}`}
|
||||
data-help-context-id="views.selector"
|
||||
data-help-module-id="views"
|
||||
data-help-scope="action"
|
||||
title={title}
|
||||
aria-label={translateText("i18n:govoplan-views.current_view")}
|
||||
aria-invalid={Boolean(error) || undefined}
|
||||
onChange={(event) => select(event.target.value)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<ViewIcon size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="dropdown-menu titlebar-view-menu" role="menu">
|
||||
<div className="titlebar-view-menu-heading">
|
||||
<strong>i18n:govoplan-views.view_selector</strong>
|
||||
{locked && <LockKeyhole size={15} aria-hidden="true" />}
|
||||
</div>
|
||||
|
||||
{!locked && (
|
||||
<option value="">
|
||||
{translateText("i18n:govoplan-views.full_interface")}
|
||||
</option>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropdown-item${specialized ? "" : " active"}`}
|
||||
disabled={busy}
|
||||
onClick={() => select("")}
|
||||
role="menuitemradio"
|
||||
aria-checked={!specialized}
|
||||
>
|
||||
<span>i18n:govoplan-views.full_interface</span>
|
||||
{!specialized && <Check size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeViewId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
className={`dropdown-item${active ? " active" : ""}`}
|
||||
disabled={busy || locked}
|
||||
onClick={() => select(option.id)}
|
||||
role="menuitemradio"
|
||||
aria-checked={active}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
{active && <Check size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{(locked || diagnostic) && (
|
||||
<p className="titlebar-view-menu-note">
|
||||
{locked
|
||||
? "i18n:govoplan-views.required_by_administrator"
|
||||
: diagnostic?.message}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" compact resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-26
@@ -1,32 +1,39 @@
|
||||
.titlebar-view-selector {
|
||||
display: inline-flex;
|
||||
.titlebar-view-menu {
|
||||
width: min(300px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.titlebar-view-menu-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 34px;
|
||||
padding: 4px 9px 8px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.titlebar-view-menu .dropdown-item > span {
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.titlebar-view-selector select {
|
||||
width: min(190px, 24vw);
|
||||
height: 32px;
|
||||
padding: 0 28px 0 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
.titlebar-view-menu .dropdown-item > svg {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.titlebar-view-selector select:hover,
|
||||
.titlebar-view-selector select:focus-visible {
|
||||
background-color: var(--titlebar-hover-bg);
|
||||
}
|
||||
|
||||
.titlebar-view-selector select:disabled {
|
||||
.titlebar-view-menu .dropdown-item:disabled {
|
||||
cursor: default;
|
||||
opacity: .78;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.titlebar-view-menu-note {
|
||||
margin: 8px 9px 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.views-management-layout {
|
||||
@@ -314,10 +321,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.titlebar-view-selector select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.views-admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user