Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
013f05839b | ||
|
|
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
|
use their normal permission checks. Required Views retain the View selector and
|
||||||
administration surfaces needed to inspect and change the assignment, preventing
|
administration surfaces needed to inspect and change the assignment, preventing
|
||||||
administrators from locking an installation out of its own configuration.
|
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
|
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
|
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 |
|
| 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.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.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 |
|
| `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]
|
[project]
|
||||||
name = "govoplan-views"
|
name = "govoplan-views"
|
||||||
version = "0.1.15"
|
version = "0.1.18"
|
||||||
description = "Governed task-focused interface projections for GovOPlaN."
|
description = "Governed task-focused interface projections for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.15"]
|
dependencies = ["govoplan-core>=0.1.18"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Views module."""
|
"""GovOPlaN Views module."""
|
||||||
|
|
||||||
__version__ = "0.1.15"
|
__version__ = "0.1.18"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from govoplan_views.backend.db import models as view_models
|
|||||||
|
|
||||||
MODULE_ID = "views"
|
MODULE_ID = "views"
|
||||||
MODULE_NAME = "Views"
|
MODULE_NAME = "Views"
|
||||||
MODULE_VERSION = "0.1.15"
|
MODULE_VERSION = "0.1.18"
|
||||||
|
|
||||||
DEFINITION_READ_SCOPE = "views:definition:read"
|
DEFINITION_READ_SCOPE = "views:definition:read"
|
||||||
DEFINITION_WRITE_SCOPE = "views:definition:write"
|
DEFINITION_WRITE_SCOPE = "views:definition:write"
|
||||||
@@ -303,8 +303,10 @@ manifest = ModuleManifest(
|
|||||||
"and tenant administrators can publish Views and make them "
|
"and tenant administrators can publish Views and make them "
|
||||||
"available, default, or required at system, tenant, group, and "
|
"available, default, or required at system, tenant, group, and "
|
||||||
"user scope. Required Views retain administration escape surfaces "
|
"user scope. Required Views retain administration escape surfaces "
|
||||||
"so they can always be inspected and changed. Hidden functions "
|
"so they can always be inspected and changed. The titlebar eye "
|
||||||
"remain protected by their normal permission checks."
|
"opens the selector and is accented while a specialized View is "
|
||||||
|
"active. Hidden functions remain protected by their normal "
|
||||||
|
"permission checks."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/views-webui",
|
"name": "@govoplan/views-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"./styles/views.css": "./src/styles/views.css"
|
"./styles/views.css": "./src/styles/views.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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(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(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("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");
|
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.");
|
console.log("Views surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Eye, LockKeyhole } from "lucide-react";
|
import { Check, LockKeyhole, View as ViewIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
DismissibleAlert,
|
||||||
dispatchPlatformViewChanged,
|
dispatchPlatformViewChanged,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -15,10 +16,21 @@ export default function ViewSelector({
|
|||||||
}: ViewSelectorProps) {
|
}: ViewSelectorProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestNavigation } = useUnsavedChanges();
|
||||||
const options = projection?.availableViews ?? [];
|
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;
|
if (!projection?.activeViewId && options.length === 0) return null;
|
||||||
|
|
||||||
async function performSelect(viewId: string) {
|
async function performSelect(viewId: string) {
|
||||||
@@ -27,6 +39,7 @@ export default function ViewSelector({
|
|||||||
try {
|
try {
|
||||||
await selectEffectiveView(settings, viewId || null);
|
await selectEffectiveView(settings, viewId || null);
|
||||||
dispatchPlatformViewChanged();
|
dispatchPlatformViewChanged();
|
||||||
|
setOpen(false);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(
|
setError(
|
||||||
caught instanceof Error
|
caught instanceof Error
|
||||||
@@ -45,6 +58,8 @@ export default function ViewSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const locked = Boolean(projection?.locked);
|
const locked = Boolean(projection?.locked);
|
||||||
|
const activeViewId = projection?.activeViewId ?? "";
|
||||||
|
const specialized = projection?.projectionActive ?? Boolean(activeViewId);
|
||||||
const diagnostic = projection?.diagnostics.find((item) => item.severity === "error")
|
const diagnostic = projection?.diagnostics.find((item) => item.severity === "error")
|
||||||
?? projection?.diagnostics[0];
|
?? projection?.diagnostics[0];
|
||||||
const title = translateText(error || diagnostic?.message || (
|
const title = translateText(error || diagnostic?.message || (
|
||||||
@@ -54,27 +69,75 @@ export default function ViewSelector({
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="titlebar-view-selector" title={title}>
|
<div className="context-menu-wrap titlebar-view-menu-wrap" ref={menuRef}>
|
||||||
{locked
|
<button
|
||||||
? <LockKeyhole size={16} aria-hidden="true" />
|
type="button"
|
||||||
: <Eye size={16} aria-hidden="true" />}
|
className={`titlebar-icon-link${specialized ? " is-context-active" : ""}`}
|
||||||
<span className="sr-only">i18n:govoplan-views.current_view</span>
|
data-help-context-id="views.selector"
|
||||||
<select
|
data-help-module-id="views"
|
||||||
value={projection?.activeViewId ?? ""}
|
data-help-scope="action"
|
||||||
disabled={busy || locked}
|
title={title}
|
||||||
aria-label={translateText("i18n:govoplan-views.current_view")}
|
aria-label={translateText("i18n:govoplan-views.current_view")}
|
||||||
aria-invalid={Boolean(error) || undefined}
|
aria-haspopup="menu"
|
||||||
onChange={(event) => select(event.target.value)}
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
{!locked && (
|
<ViewIcon size={18} aria-hidden="true" />
|
||||||
<option value="">
|
</button>
|
||||||
{translateText("i18n:govoplan-views.full_interface")}
|
|
||||||
</option>
|
{open && (
|
||||||
)}
|
<div className="dropdown-menu titlebar-view-menu" role="menu">
|
||||||
{options.map((option) => (
|
<div className="titlebar-view-menu-heading">
|
||||||
<option key={option.id} value={option.id}>{option.name}</option>
|
<strong>i18n:govoplan-views.view_selector</strong>
|
||||||
))}
|
{locked && <LockKeyhole size={15} aria-hidden="true" />}
|
||||||
</select>
|
</div>
|
||||||
</label>
|
|
||||||
|
{!locked && (
|
||||||
|
<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) => {
|
||||||
|
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 {
|
.titlebar-view-menu {
|
||||||
display: inline-flex;
|
width: min(300px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-view-menu-heading {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
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;
|
min-width: 0;
|
||||||
gap: 7px;
|
overflow: hidden;
|
||||||
color: var(--muted);
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar-view-selector select {
|
.titlebar-view-menu .dropdown-item > svg {
|
||||||
width: min(190px, 24vw);
|
flex: 0 0 auto;
|
||||||
height: 32px;
|
margin-left: auto;
|
||||||
padding: 0 28px 0 8px;
|
color: var(--accent);
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar-view-selector select:hover,
|
.titlebar-view-menu .dropdown-item:disabled {
|
||||||
.titlebar-view-selector select:focus-visible {
|
|
||||||
background-color: var(--titlebar-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titlebar-view-selector select:disabled {
|
|
||||||
cursor: default;
|
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 {
|
.views-management-layout {
|
||||||
@@ -314,10 +321,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.titlebar-view-selector select {
|
|
||||||
width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.views-admin-shell {
|
.views-admin-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user