diff --git a/pyproject.toml b/pyproject.toml index 643e729..60ad992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-campaign" -version = "0.1.18" +version = "0.1.19" description = "GovOPlaN campaigns module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 707bdec..1d939de 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -36,6 +36,7 @@ from govoplan_core.core.modules import ( NavItem, PermissionDefinition, ProductAreaContribution, + QuickAccessTool, RoleTemplate, ) from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX @@ -384,7 +385,7 @@ def _campaigns_router(context: ModuleContext): manifest = ModuleManifest( id="campaigns", name="Campaigns", - version="0.1.18", + version="0.1.19", required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -617,6 +618,32 @@ manifest = ModuleManifest( label="Campaign activity widget", order=50, ), + ViewSurface( + id="campaigns.quick_access.campaigns", + module_id="campaigns", + kind="quick_access", + label="Campaign selection", + order=55, + ), + ), + quick_access_tools=( + QuickAccessTool( + id="campaigns.select", + module_id="campaigns", + category_id="campaigns", + label="Campaigns", + description="Select an authorized exact Campaign for the active Case.", + surface_id="campaigns.quick_access.campaigns", + icon="campaign", + full_page_path="/campaigns", + required_any=("campaigns:campaign:read",), + order=20, + modes=("select",), + availability="active_object", + accepted_reference_kinds=("cases.case",), + returned_reference_kinds=("campaigns.campaign",), + help_context_id="campaigns.quick_access.campaigns", + ), ), ), migration_spec=MigrationSpec( @@ -668,6 +695,48 @@ manifest = ModuleManifest( ), documentation=( *CAMPAIGN_USER_DOCUMENTATION, + DocumentationTopic( + id="campaigns.workflow.link-exact-campaign-to-case", + title="Link an exact Campaign reference to an active Case", + summary=( + "Return an authorized Campaign and current immutable version through " + "Quick Access without copying campaign content." + ), + body=( + "When a Case is the active object, Campaigns contributes a bounded " + "Quick Access selector. The normal Campaign list endpoint applies the " + "current actor's tenant, owner, group, share, and administration access " + "before candidates appear. Selecting a Campaign returns only its owner " + "module, stable Campaign ID, current version ID, display label, tenant, " + "and owner route through the versioned result contract. Cases discards " + "the label and stores no recipient, message, attachment, delivery, report, " + "or campaign configuration content. Opening the reference enters Campaigns " + "and rechecks current access. Campaigns disabled, access revoked, or the " + "source removed therefore leaves only an unavailable historical Case " + "reference; it never turns Case access into Campaign access." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("campaign_manager", "case_manager", "operator", "module_admin"), + related_modules=("cases", "quick_access"), + conditions=( + DocumentationCondition( + required_modules=("campaigns", "cases"), + required_scopes=("campaigns:campaign:read",), + ), + ), + links=( + DocumentationLink( + label="Campaigns", + href="/campaigns", + kind="runtime", + ), + ), + metadata={ + "kind": "workflow", + "help_contexts": ["campaigns.quick_access.campaigns"], + }, + ), DocumentationTopic( id="campaigns.privacy.data-subject-requests", title="Review Campaign data in a data-subject request", diff --git a/tests/test_manifest_navigation.py b/tests/test_manifest_navigation.py index f046152..a08b76a 100644 --- a/tests/test_manifest_navigation.py +++ b/tests/test_manifest_navigation.py @@ -73,3 +73,21 @@ def test_reusable_template_library_is_not_owned_by_campaign() -> None: assert "/templates" not in {item.path for item in manifest.nav_items} assert "/templates" not in {item.path for item in manifest.frontend.nav_items} assert "/templates" not in {route.path for route in manifest.frontend.routes} + + +def test_quick_access_selects_exact_campaigns_only_for_active_cases() -> None: + manifest = get_manifest() + assert manifest.frontend is not None + + tool = next( + item + for item in manifest.frontend.quick_access_tools + if item.id == "campaigns.select" + ) + assert tool.availability == "active_object" + assert tool.accepted_reference_kinds == ("cases.case",) + assert tool.returned_reference_kinds == ("campaigns.campaign",) + assert tool.modes == ("select",) + assert "campaigns.workflow.link-exact-campaign-to-case" in { + topic.id for topic in manifest.documentation + } diff --git a/webui/package.json b/webui/package.json index fd7a2f7..56ca7dd 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/campaign-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/features/campaigns/CampaignQuickAccess.tsx b/webui/src/features/campaigns/CampaignQuickAccess.tsx new file mode 100644 index 0000000..75a1c18 --- /dev/null +++ b/webui/src/features/campaigns/CampaignQuickAccess.tsx @@ -0,0 +1,74 @@ +import { Megaphone, X } from "lucide-react"; +import { useCallback } from "react"; + +import { + Button, + DashboardWidgetList, + DismissibleAlert, + LoadingFrame, + useDashboardWidgetData, + type QuickAccessToolRenderContext +} from "@govoplan/core-webui"; +import { listCampaigns } from "../../api/campaigns"; + + +type Props = Pick< + QuickAccessToolRenderContext, + "settings" | "launchContext" | "complete" | "cancel" +>; + +export default function CampaignQuickAccess({ + settings, + launchContext, + complete, + cancel +}: Props) { + const load = useCallback( + async () => (await listCampaigns(settings)) + .filter((campaign) => Boolean(campaign.current_version_id)) + .slice(0, 12), + [settings] + ); + const { data, loading, error } = useDashboardWidgetData(load, 0); + + return ( + +

+ Select an exact Campaign version for {launchContext.activeObject?.label}. + Campaign content remains in Campaigns and access is checked again when opened. +

+ {error ? ( + {error} + ) : null} + ({ + id: campaign.id, + title: campaign.name, + detail: campaign.description || campaign.status, + meta: campaign.status, + leading:
+ ); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index 5bf928a..5b5e018 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -6,10 +6,12 @@ import { type ApiSettings, type AuthInfo, type DashboardWidgetsUiCapability, - type PlatformWebModule + type PlatformWebModule, + type QuickAccessToolsUiCapability } from "@govoplan/core-webui"; import { getCampaign } from "./api/campaigns"; import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget"; +import CampaignQuickAccess from "./features/campaigns/CampaignQuickAccess"; import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess"; import { generatedTranslations } from "./i18n/generatedTranslations"; import { campaignWizardDirectories } from "./features/campaigns/wizard/directory"; @@ -72,6 +74,14 @@ const campaignDashboardWidgets: DashboardWidgetsUiCapability = { } ] }; +const campaignQuickAccessTools: QuickAccessToolsUiCapability = { + tools: [ + { + id: "campaigns.select", + render: (context) => createElement(CampaignQuickAccess, context) + } + ] +}; export const campaignModule: PlatformWebModule = { id: "campaigns", @@ -100,6 +110,7 @@ export const campaignModule: PlatformWebModule = { { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }], uiCapabilities: { "dashboard.widgets": campaignDashboardWidgets, + "quickAccess.tools": campaignQuickAccessTools, "wizard.directories": campaignWizardDirectories }