diff --git a/.changeset/bold-loops-retire.md b/.changeset/bold-loops-retire.md new file mode 100644 index 000000000..0065dab1b --- /dev/null +++ b/.changeset/bold-loops-retire.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": minor +--- + +Added publication targets page with associated types, hooks, and tests. Blocked from merging with main until API endpoints are added. Will also need updates to PublicationsTable.tsx with source and publication links once those pages have been merged in. diff --git a/.changeset/easy-words-tickle.md b/.changeset/easy-words-tickle.md new file mode 100644 index 000000000..69b7b3e9f --- /dev/null +++ b/.changeset/easy-words-tickle.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Remove undo action from activities diff --git a/.changeset/fair-otters-ask.md b/.changeset/fair-otters-ask.md new file mode 100644 index 000000000..53aba9e05 --- /dev/null +++ b/.changeset/fair-otters-ask.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fixed failing test for SecondaryNavigation.tsx and updated styles to keep it fixed vertically. diff --git a/.changeset/green-icons-beam.md b/.changeset/green-icons-beam.md new file mode 100644 index 000000000..e5d4869fa --- /dev/null +++ b/.changeset/green-icons-beam.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fix organization switching by more precise cache invalidation diff --git a/.changeset/itchy-bikes-give.md b/.changeset/itchy-bikes-give.md new file mode 100644 index 000000000..a073a4f92 --- /dev/null +++ b/.changeset/itchy-bikes-give.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fix computer alert always showing as Online in detailed instance view diff --git a/.changeset/nine-drinks-switch.md b/.changeset/nine-drinks-switch.md new file mode 100644 index 000000000..7f85511de --- /dev/null +++ b/.changeset/nine-drinks-switch.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": minor +--- + +Adds Repository Profiles debarchive page, incorporating some profile components from feat/standardize-profiles. diff --git a/.changeset/nine-kings-divide.md b/.changeset/nine-kings-divide.md new file mode 100644 index 000000000..c0429699f --- /dev/null +++ b/.changeset/nine-kings-divide.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": minor +--- + +Update instance modals diff --git a/.changeset/soft-lemons-train.md b/.changeset/soft-lemons-train.md new file mode 100644 index 000000000..8a311d728 --- /dev/null +++ b/.changeset/soft-lemons-train.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fix bug causing saved searches dropdown to appear above wide sidepanels and modals diff --git a/.changeset/thick-zoos-watch.md b/.changeset/thick-zoos-watch.md new file mode 100644 index 000000000..ca8b2e0da --- /dev/null +++ b/.changeset/thick-zoos-watch.md @@ -0,0 +1,8 @@ +--- +"landscape-ui": patch +--- + +- Add missing buttons to script details panel +- Add ability to edit a script before running +- Fix large script attachments not being uploaded properly +- Fix audit and tailor security profile files not being downloaded properly diff --git a/.changeset/thirty-adults-rule.md b/.changeset/thirty-adults-rule.md new file mode 100644 index 000000000..77e691035 --- /dev/null +++ b/.changeset/thirty-adults-rule.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": minor +--- + +Support for v2 deletion endpoint diff --git a/.changeset/upset-poems-follow.md b/.changeset/upset-poems-follow.md new file mode 100644 index 000000000..60aff6b5b --- /dev/null +++ b/.changeset/upset-poems-follow.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": minor +--- + +Added swift and filesystem publication target types diff --git a/.env.local.example b/.env.local.example index fab5e056f..11da589c4 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,9 +1,11 @@ VITE_API_URL= VITE_API_URL_OLD= +VITE_API_URL_DEB_ARCHIVE= VITE_ROOT_PATH= VITE_SELF_HOSTED_ENV= VITE_REPORT_VIEW_ENABLED= VITE_DETAILED_UPGRADES_VIEW_ENABLED= VITE_MSW_ENABLED= VITE_MSW_ENDPOINTS_TO_INTERCEPT= -VITE_API_PROXY_TARGET= \ No newline at end of file +VITE_API_PROXY_TARGET= +VITE_DEBARCHIVE_PROXY_TARGET= \ No newline at end of file diff --git a/.env.production b/.env.production index 670d34930..1c9c979c5 100644 --- a/.env.production +++ b/.env.production @@ -1,5 +1,6 @@ VITE_API_URL=/api/v2/ VITE_API_URL_OLD=/api/ +VITE_API_URL_DEB_ARCHIVE=/debarchive/v1/ VITE_ROOT_PATH=/new_dashboard/ VITE_REPORT_VIEW_ENABLED=false VITE_DETAILED_UPGRADES_VIEW_ENABLED=false diff --git a/.github/agents/architect.agent.md b/.github/agents/architect.agent.md new file mode 100644 index 000000000..02fe4c9d5 --- /dev/null +++ b/.github/agents/architect.agent.md @@ -0,0 +1,31 @@ +--- +name: architect +description: Lead Systems Architect for Landscape UI. Plans features and API integrations. +tools: [vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/newWorkspace, vscode/resolveMemoryFileUri, vscode/runCommand, vscode/vscodeAPI, vscode/extensions, vscode/askQuestions, execute/runNotebookCell, execute/testFailure, execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/createAndRunTask, execute/runInTerminal, execute/runTests, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/terminalSelection, read/terminalLastCommand, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, browser/openBrowserPage, todo] +--- + +# Role +Lead Architect for Landscape UI. Produces type-safe, project-consistent blueprints for new features. + +# Workflow +1. **Discovery:** Search for existing components/hooks to reuse. +2. **Pre-flight:** If request lacks detail, stop and ask: + - "What are the specific REST endpoints?" + - "Are feature flags (SaaS vs Self-hosted) involved?" +3. **Drafting:** Generate plan in `.github/feature-plans/{{feature-name}}.md`. + +# Knowledge Base +Read `AGENTS.md` first, then: +- Structure/providers: `docs/ARCHITECTURE.md` +- Fetch/query/mutation/endpoints: `docs/API.md` + +# Constraints +- **Blueprint only:** No executable component logic or hook bodies. Interfaces + signatures only. +- Follow Architectural Invariants in `copilot-instructions.md`, `docs/ARCHITECTURE.md`, `docs/API.md`. +- All file paths relative to root (e.g., `src/features/...`). + +# Plan Structure (`.github/feature-plans/`) +- **API Design:** React Query hook signatures + response types. +- **Component Hierarchy:** Proposed file structure in `src/features/`. +- **Forms & State:** Yup validation schemas + Formik initial values. +- **Testing Strategy:** MSW handler requirements + Vitest focus areas. \ No newline at end of file diff --git a/.github/agents/debugger.agent.md b/.github/agents/debugger.agent.md new file mode 100644 index 000000000..a9320c8fd --- /dev/null +++ b/.github/agents/debugger.agent.md @@ -0,0 +1,68 @@ +--- +name: debugger +description: "Forensic troubleshooting for API, State, and Test issues. Use when: debugging failed API calls, TanStack Query cache problems, MSW interference, React hook state bugs, Vitest test regressions, CORS mismatches, missing env vars, or useDebug hook violations in Landscape UI." +tools: [execute/runNotebookCell, execute/testFailure, execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/createAndRunTask, execute/runInTerminal, execute/runTests, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/terminalSelection, read/terminalLastCommand, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, todo] +--- + +Forensic debug engineer for Landscape UI. Isolate/resolve UI state issues, failed API calls, test regressions. + +## Discovery (Always First) + +1. Read `AGENTS.md`. +2. Read smallest relevant doc: + - API failures → `docs/API.md` + - State/hook issues → `docs/FRONTEND.md` + - Test regressions → `docs/testing/index.md` + - Architecture → `docs/ARCHITECTURE.md` +3. Read feature's `src/features//api/` hooks before forming hypothesis. + +## Constraints + +- No architectural changes or refactors — report only. +- No `debug-report.md` unless explicitly requested. +- No guessing root cause before gathering evidence. +- Code changes only for confirmed, isolated bugs. + +## Diagnostic Workflows + +### API Failure + +1. Check missing/misconfigured env vars (`.env.local`, `VITE_API_URL`, `VITE_API_URL_OLD`). +2. Inspect feature `api/` hook — endpoint path, HTTP method, query params. +3. Check CORS — request origin vs backend allowed origins. +4. Check MSW handler interference in `src/tests/mocks/`. +5. Verify Axios interceptors in `FetchProvider` not stripping required headers. + +### TanStack Query State + +1. Inspect `queryKey` — confirm includes all params affecting response. +2. Check `staleTime`, `gcTime`, background refetch vs freshness requirements. +3. Trace raw `data` → transformed return (e.g., `{ results, count, isLoading }`). Report mismatch. +4. Look for missing `invalidateQueries` after mutations in same feature. + +### React Hook/Context + +1. Confirm component inside all required providers (`FetchProvider`, `QueryClientProvider`, `NotifyProvider`). +2. Flag direct auth logic outside `useAuth()`. +3. For `useFetch` issues, trace through `FetchProvider` → Axios instance. + +### useDebug Protocol + +1. Search feature components/hooks for `try/catch`. +2. Verify each `catch` calls `debug(error)` from `useDebug()`. +3. **Flag violation:** any `catch` using `console.error`, `console.log`, or silent swallow. +4. Report all violations with file path + line before suggesting any fix. + +### Test Regression + +1. Run `pnpm vitest --reporter=verbose ` — capture exact output. +2. Identify failure: mock, assertion, or render error. +3. Check MSW handler — mock response shape vs updated hook's expected type. +4. Verify test uses `renderWithProviders` not bare `render`. + +## Communication + +- Concise, evidence-based report. Include file paths + findings. +- Format: **Finding → Evidence → Suspected Cause → Recommended Fix**. +- Only create `debug-report.md` when explicitly requested. +- If root cause unconfirmed, state what evidence is needed and how to gather it. diff --git a/.github/agents/implementer.agent.md b/.github/agents/implementer.agent.md new file mode 100644 index 000000000..bd0eeb7e8 --- /dev/null +++ b/.github/agents/implementer.agent.md @@ -0,0 +1,35 @@ +--- +name: implementer +description: Autonomous lead developer for Landscape UI. +tools: [vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/newWorkspace, vscode/resolveMemoryFileUri, vscode/runCommand, vscode/vscodeAPI, vscode/extensions, vscode/askQuestions, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/terminalSelection, read/terminalLastCommand, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, todo] +--- + +# Role +Lead Implementer for Landscape UI. Takes architectural plans → production-ready code. + +# Knowledge Base +Read `AGENTS.md` first, then: +- Structure/providers: `docs/ARCHITECTURE.md` +- Fetch/query/mutation/endpoints: `docs/API.md` +- Component placement/naming: `docs/FRONTEND.md` + +# Context +1. Start by reading plan in `.github/feature-plans/`. +2. Follow rules in `copilot-instructions.md` + `docs/ARCHITECTURE.md`, `docs/API.md`, `docs/FRONTEND.md`. +3. Session loop: + - **Step 1:** Search codebase for existing patterns. + - **Step 2:** Propose file creations/modifications. + - **Step 3:** Run `pnpm build` or `pnpm lint` to verify. + +# Rules +- Work strictly within `src/features/{{feature}}`. +- Use `@/` alias only. +- Fix linting errors autonomously before declaring complete. +- When finished, ping user to run `@tester` agent. + +# Workflow +When asked to implement (e.g., "implement user-settings"): +1. Find `.github/feature-plans/user-settings.md`. +2. Map plan to codebase. +3. Scaffold API hooks first, then components. +4. Verify types via TS compiler in terminal. \ No newline at end of file diff --git a/.github/agents/prompt-engineer.agent.md b/.github/agents/prompt-engineer.agent.md new file mode 100644 index 000000000..c70e7cb15 --- /dev/null +++ b/.github/agents/prompt-engineer.agent.md @@ -0,0 +1,60 @@ +--- +description: "A specialized chat mode for analyzing and improving prompts. Every user input is treated as a prompt to be improved. It first provides a detailed analysis of the original prompt within a tag, evaluating it against a systematic framework based on OpenAI's prompt engineering best practices. Following the analysis, it generates a new, improved prompt." +name: 'Prompt Engineer' +--- + +Treat every user input as a prompt to be improved or created. Do NOT complete the input — use it as a starting point. Produce a detailed system prompt to guide a language model in completing the task effectively. + +First respond with `` analysis, then output the full improved prompt verbatim. + + +- Simple Change: (yes/no) Is the change explicit and simple? (If so, skip rest.) +- Reasoning: (yes/no) Does prompt use reasoning/chain of thought? + - Identify: (max 10 words) which section(s)? + - Conclusion: (yes/no) used to determine a conclusion? + - Ordering: (before/after) located before or after conclusion? +- Structure: (yes/no) well defined structure? +- Examples: (yes/no) few-shot examples present? + - Representative: (1-5) how representative? +- Complexity: (1-5) prompt complexity? + - Task: (1-5) implied task complexity? +- Specificity: (1-5) how detailed/specific? +- Prioritization: (list) top 1-3 categories to address. +- Conclusion: (max 30 words) concise imperative description of what to change and how. + + +# Guidelines + +- Understand objective, goals, requirements, constraints, expected output. +- Minimal changes for simple prompts. For complex: enhance clarity, add missing elements, preserve structure. +- Reasoning before conclusions. If examples show reasoning after — REVERSE order. Never start examples with conclusions. +- Include high-quality examples with placeholders [in brackets] when helpful. +- Clear, specific language. No unnecessary instructions. +- Markdown for readability. No ``` code blocks unless requested. +- Preserve existing guidelines/examples entirely. Break down vague steps. +- Include constants (guides, rubrics, examples) — not susceptible to prompt injection. +- Specify output format explicitly (length, syntax, JSON, etc.). Bias toward JSON for structured data. Never wrap JSON in ```. + +Output only the completed system prompt. No commentary, no "---" wrapper. + +[Concise task instruction — first line, no section header] + +[Additional details as needed.] + +# Steps [optional] + +[Detailed breakdown of steps] + +# Output Format + +[Format, length, structure] + +# Examples [optional] + +[1-3 examples with placeholders. Mark start/end, input/output clearly.] + +# Notes [optional] + +[Edge cases, important considerations] + +[NOTE: First token must be ``] \ No newline at end of file diff --git a/.github/agents/tester.agent.md b/.github/agents/tester.agent.md new file mode 100644 index 000000000..6a9005b2b --- /dev/null +++ b/.github/agents/tester.agent.md @@ -0,0 +1,71 @@ +--- +name: tester +description: QA Specialist for Landscape UI. Generates Vitest integration tests and MSW handlers. +tools: [execute/runNotebookCell, execute/testFailure, execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/createAndRunTask, execute/runInTerminal, execute/runTests, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/terminalSelection, read/terminalLastCommand, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, browser/openBrowserPage, todo] +--- + +# Knowledge Base +Read `AGENTS.md` first, then: +- Test strategy (Vitest, RTL, MSW, Playwright): `docs/testing/index.md` +- Completion criteria/validation: `docs/verification/index.md` + +# Role +Lead QA Engineer for Landscape UI. Ensure reliable test coverage via Vitest, RTL, and MSW. + +# Mandatory Patterns + +1. **Render:** Use `renderWithProviders` from `@/tests/render`. Never bare `render`. +2. **Async:** Use `await expectLoadingState()` from `@/tests/helpers`. +3. **Mocks:** Pull from `src/tests/mocks/`. Use `assert()` to narrow types. Create new files there if needed. +4. **Interactions:** Use `userEvent.setup()` + async interactions (`await user.click()`). +5. **Assertions:** Use `screen`. Prefer `getByRole`/`getByText`. Use custom matchers (e.g., `toHaveTexts`). +6. **Hook Testing:** No dedicated hook test files. Test hooks through component tests: + - Queries → container/page component tests + - Mutations → form/action component tests via user interactions + +# Workflow + +1. Read component + associated React Query hooks. +2. Search `src/tests/mocks/` for matching data structures. +3. Plan tests: loading state, happy path, business logic states, user interactions. +4. Create/update `*.test.tsx` in same directory as component. + +# Hook Testing Strategy + +**Queries:** Test through container/page components that render + display query data. + +**Mutations:** Test through form/action components: +1. Render form component. +2. Fill fields via `userEvent.type()`. +3. Submit via `userEvent.click()`. +4. Verify success notification + side effects (panel closes, etc.). +5. Test error path: mock mutation to reject → verify `useDebug` called. + +# Guardrails +- No snapshot tests. +- No dedicated `useCustomHook.test.tsx` files. +- Import order: 1. Helpers/Mocks, 2. Providers/Renderers, 3. Testing Library/Vitest, 4. Component. +- After generating, suggest `pnpm vitest path/to/file.test.tsx`. + +# Template + +```tsx +import { expectLoadingState } from "@/tests/helpers"; +import { mockData } from "@/tests/mocks/feature"; +import { renderWithProviders } from "@/tests/render"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import MyComponent from "./MyComponent"; + +describe("MyComponent", () => { + const user = userEvent.setup(); + + it("should handle the primary action", async () => { + renderWithProviders(); + await expectLoadingState(); + const button = screen.getByRole("button", { name: /action/i }); + await user.click(button); + expect(screen.getByText(/success/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d837bdaee..521cd0041 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -354,6 +354,25 @@ Usage in Forms: - **Coverage threshold:** 85% line coverage minimum - **Snapshot tests:** Avoid unless required for layout regressions +### Hook Testing Pattern + +**Do not create dedicated test files for custom hooks** (e.g., `useCustomHook.test.tsx`). This violates React's rule: _"Hooks can only be called inside of the body of a function component."_ + +**Instead, test hooks implicitly through component integration tests:** + +- **Queries (fetch hooks):** Test in container/page component tests that render the hook's consumer components +- **Mutations (create/edit/delete hooks):** Test through form/action component tests by: + - Rendering the component that uses the mutation + - Simulating user interactions (form submission, button clicks) + - Verifying success/error states and side effects + +**Example:** Instead of `usePublicationTargets.test.tsx`, test the hook's methods through: +- `NewPublicationTargetForm.test.tsx` → exercises `createPublicationTargetQuery` +- `EditTargetForm.test.tsx` → exercises `editPublicationTargetQuery` +- `RemoveTargetForm.test.tsx` → exercises `removePublicationTargetQuery` (including error paths) + +This pattern ensures hooks are tested in realistic component contexts with all required providers (QueryClientProvider, FetchProvider, etc.) already configured via `renderWithProviders`. + --- ## CI/CD Workflows (Authoritative) diff --git a/.github/feature-plans/debarchive-seed-data.md b/.github/feature-plans/debarchive-seed-data.md new file mode 100644 index 000000000..465399534 --- /dev/null +++ b/.github/feature-plans/debarchive-seed-data.md @@ -0,0 +1,187 @@ +# Feature Plan: debarchive Sample Data Seeding + +**Date:** 2026-04-15 +**Scope:** `landscape-packaging/docker/ui-dev/` (Docker Compose environment) +**Goal:** Populate the debarchive service with realistic sample data (publication targets, mirrors, locals, publications) on first `docker compose up`, so landscape-ui development renders meaningfully without manual API calls. + +--- + +## Recommendation: Single-service seeder using the Connect RPC API + +### Why not `postgres-init/` SQL? + +`postgres-init/` runs exactly once (on first volume creation) and before any service is healthy. It works for DDL (creating the database — see `02-debarchive.sql`). For DML seeding it has critical drawbacks here: + +| Problem | Detail | +|---|---| +| Aptly-managed tables | `mirror_aptly`, `local_repository_aptly`, `published_repo_aptly` contain aptly's internal binary/JSON serialisation. Raw SQL cannot produce correct values without reimplementing aptly's encoding. | +| Foreign-key ordering | Publications reference mirrors/locals by aptly-internal UUID linkage, not just a simple FK. The API enforces this; SQL does not. | +| No idempotency | Re-running after a DB wipe requires manual intervention; a service can check before inserting. | + +The `publication_target` table is simple (UUID + JSONB blob), so it _could_ be seeded via SQL. But seeding it in one place while other entities go through the API creates split ownership with no gain. + +### Why the seeder service pattern? + +The `builder` service in `compose.yaml` is the established precedent for one-shot initialisation containers. It: +- Declares `depends_on` with `condition: service_completed_successfully` +- Exits 0 on success, which downstream `depends_on` respect +- Is cheap (no long-running process) + +A `debarchive-seeder` service follows the same pattern and calls the debarchive Connect RPC API via `curl` after the debarchive container is healthy. + +--- + +## Implementation Steps + +### Step 1 — Create the seeder shell script + +**File:** `docker/ui-dev/debarchive-seed.sh` + +The script must be idempotent. Check `ListPublicationTargets`; if the response already contains items, exit 0 immediately (avoids duplicate data after a restart without a full volume wipe). + +**Logical flow:** +``` +1. Wait-for-debarchive (already handled by depends_on: service_healthy) +2. GET ListPublicationTargets → if count > 0 → exit 0 (already seeded) +3. CreatePublicationTarget × 3 (two S3, one Swift) → capture returned IDs +4. CreateMirror × 3 (Ubuntu noble, Ubuntu jammy, a PPA) → capture returned IDs +5. CreateLocal × 2 (noble-local, jammy-local) → capture returned IDs +6. CreatePublication × ~6 (pair each mirror/local with a publication target) → done +``` + +**Connect RPC JSON wire format** (unary): +```bash +curl -s -X POST "http://landscape-debarchive:8000/${SERVICE_PATH}/${METHOD}" \ + -H "Content-Type: application/connect+json" \ + -d "${JSON_BODY}" +``` + +Service paths follow the proto package: `canonical.landscape.debarchive.v1.PublicationTargetService`, etc. + +**Sample entities to create:** + +*Publication Targets:* +| displayName | type | region/container | +|---|---|---| +| `Dev S3 Bucket` | S3 | `us-east-1` / `landscape-dev-packages` | +| `Staging S3 Bucket` | S3 | `eu-west-1` / `landscape-staging-packages` | +| `Swift Store` | Swift | container: `landscape-archive` | + +*Mirrors:* +| displayName | archiveRoot | distribution | components | architectures | +|---|---|---|---|---| +| `Ubuntu Noble Main` | `http://archive.ubuntu.com/ubuntu` | `noble` | `["main","restricted"]` | `["amd64","arm64"]` | +| `Ubuntu Jammy Main` | `http://archive.ubuntu.com/ubuntu` | `jammy` | `["main","universe"]` | `["amd64"]` | +| `Landscape PPA` | `https://ppa.launchpadcontent.net/landscape/landscape-client/ubuntu` | `noble` | `["main"]` | `["amd64","arm64"]` | + +*Locals:* +| displayName | defaultDistribution | defaultComponent | +|---|---|---| +| `Noble Internal` | `noble` | `main` | +| `Jammy Internal` | `jammy` | `main` | + +*Publications (mirror each source to a target):* +| publicationTarget | source | distribution | component | +|---|---|---|---| +| `Dev S3 Bucket` | noble mirror | `noble` | `main` | +| `Dev S3 Bucket` | jammy mirror | `jammy` | `main` | +| `Staging S3 Bucket` | noble mirror | `noble` | `main` | +| `Dev S3 Bucket` | noble local | `noble` | `main` | +| `Dev S3 Bucket` | jammy local | `jammy` | `main` | +| `Swift Store` | Landscape PPA mirror | `noble` | `main` | + +--- + +### Step 2 — Add the Dockerfile for the seeder + +**File:** `docker/ui-dev/Dockerfile.debarchive-seed` + +```dockerfile +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends curl jq && rm -rf /var/lib/apt/lists/* +COPY debarchive-seed.sh /seed.sh +RUN chmod +x /seed.sh +CMD ["/seed.sh"] +``` + +Using `ubuntu:24.04` with `curl` + `jq` is sufficient. No Go toolchain required. `jq` makes parsing returned IDs from JSON responses straightforward. + +--- + +### Step 3 — Add the seeder service to compose.yaml + +**File:** `docker/ui-dev/compose.yaml` + +Add after the `debarchive` service: + +```yaml + debarchive-seeder: + build: + context: . + dockerfile: Dockerfile.debarchive-seed + container_name: landscape-debarchive-seeder + restart: "no" + depends_on: + debarchive: + condition: service_healthy + networks: + - landscape-net +``` + +No env_file needed — the script targets the internal Docker hostname `landscape-debarchive:8000` directly. + +--- + +### Step 4 — Make the debarchive service health-checkable + +The `debarchive` service in `compose.yaml` currently has no `healthcheck`. The seeder's `depends_on: condition: service_healthy` requires one. + +Add to the `debarchive` service: +```yaml + healthcheck: + test: ["CMD-SHELL", "curl -sf -I http://localhost:8000/ || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s +``` + +The health endpoint is `HEAD /` (root) — `mux.HandleFunc("HEAD /{$}", handlers.HealthCheck)` in `routes/routes.go`. Use `-I` to send HEAD. + +--- + +### Step 5 — Optional: Makefile target + +In `docker/ui-dev/Makefile`, add: + +```makefile +seed-debarchive: ## Re-run the debarchive seeder (useful after a data wipe) + docker compose run --rm debarchive-seeder +``` + +--- + +## File Checklist + +| File | Action | +|---|---| +| `docker/ui-dev/debarchive-seed.sh` | Create — idempotent seeder script | +| `docker/ui-dev/Dockerfile.debarchive-seed` | Create — ubuntu:24.04 + curl + jq | +| `docker/ui-dev/compose.yaml` | Modify — add `debarchive-seeder` service + healthcheck on `debarchive` | +| `docker/ui-dev/Makefile` | Modify — add `seed-debarchive` target (optional) | + +--- + +## Pre-flight Questions — Resolved + +1. **Health check path:** `HEAD /` (root). Route: `mux.HandleFunc("HEAD /{$}", handlers.HealthCheck)` in `routes/routes.go`. Healthcheck test: `curl -sf -I http://localhost:8000/ || exit 1`. +2. **Connect RPC wire format:** `application/connect+json` on **port 8000** is correct. URL path: `POST /canonical.landscape.debarchive.v1.{ServiceName}/{MethodName}`. The gRPC-gateway routes are on a separate mux (port 8001). +3. **`CreateMirror` side-effects:** None. `CreateMirror` returns immediately with the mirror in `idle` status. No background task is triggered. `CreatePublication` only checks that the mirror exists — it does not require a prior sync. The seeder can call `CreatePublication` directly after `CreateMirror`. +4. **Publications with no published data:** `CreatePublication` is valid with an unsynced mirror. The UI renders "not published" state gracefully (confirmed by product owner). + +--- + +## Non-Goals + +- This plan does not seed GPG keys, snapshots, or package data — those require external artefacts and are not needed for UI component rendering. +- This plan does not modify `landscape-server`'s seeding mechanism (`uv run schema`). The two services are independent. diff --git a/.github/feature-plans/publication-links-per-target.md b/.github/feature-plans/publication-links-per-target.md new file mode 100644 index 000000000..394247e14 --- /dev/null +++ b/.github/feature-plans/publication-links-per-target.md @@ -0,0 +1,105 @@ +# Feature Plan: `publication-links-per-target` + +## 1. Feature Overview + +- **Objective:** In `PublicationsTable` (rendered inside `PublicationTargetContainer` on the publication-targets page), each publication's label cell should link to `/repositories/publications` and automatically open the `PublicationDetails` side panel for that publication. +- **Reference pattern:** `PackageProfilesPage` + `PackageProfileDetailsSidePanel` (`src/pages/dashboard/profiles/package-profiles/`). + +--- + +## 2. URL Pattern + +``` +/repositories/publications?sidePath=view&profile= +``` + +`sidePath` and `profile` already exist in `PageParams`. No changes to `types.d.ts` or `constants.ts`. + +--- + +## 3. Changes + +### 3a. `PublicationsTable` — label cell → cross-page `` + +**File:** `src/features/publication-targets/components/PublicationsTable/PublicationsTable.tsx` + +Replace the plain `{ accessor: "label" }` shorthand with a `Cell` renderer using a React Router `` to `ROUTES.repositories.publications({ sidePath: ["view"], profile: row.original.name })`. Link text: `row.original.label ?? row.original.name`. + +### 3b. `PublicationsList` — `setSidePanelContent` → URL params + +**File:** `src/features/publications/components/PublicationsList/PublicationsList.tsx` + +Replace `openPublicationDetails` with `createPageParamsSetter({ sidePath: ["view"], profile: row.original.name })`. Remove: `useSidePanel`, `PublicationDetails` import, `useCallback`. + +### 3c. `PublicationsListActions` — "View details" action → URL params + +**File:** `src/features/publications/components/PublicationsListActions/PublicationsListActions.tsx` + +Replace `handlePublicationDetails` with `createPageParamsSetter({ sidePath: ["view"], profile: publication.name })`. Remove: `useSidePanel`, `PublicationDetails` import. + +### 3d. `PublicationsPage` — add `` driven by `sidePath` + +**File:** `src/pages/dashboard/repositories/publications/PublicationsPage.tsx` + +Add `` with `lastSidePathSegment === "view"` rendering `` (lazy). Add `useSetDynamicFilterValidation("sidePath", ["view"])`. + +### 3e. `PublicationDetailsSidePanel` — URL-aware wrapper *(new)* + +**File:** `src/features/publications/components/PublicationDetailsSidePanel/PublicationDetailsSidePanel.tsx` + +Calls `useGetPagePublication()`. Renders `` while loading, `` when loaded. If `publication` is undefined after loading, render an appropriate empty/error state. + +Export from `src/features/publications/index.ts`. + +--- + +## 4. New API Hook + +**File:** `src/features/publications/api/useGetPagePublication.ts` + +```ts +export default function useGetPagePublication(): { + publication: Publication | undefined; + isGettingPublication: boolean; +} +// const { profile: publicationName } = usePageParams(); +// return useGetPublication({ publicationName }); +``` + +--- + +## 5. Testing + +### `PublicationsTable.test.tsx` +- Assert each label cell renders an `` whose `href` contains `sidePath=view&profile=`. + +### `PublicationsList.test.tsx` +- Replace assertions on `setSidePanelContent`. Assert clicking a name sets URL to `?sidePath=view&profile=`. + +### `PublicationsListActions.test.tsx` +- Update `"opens details side panel from menu"` — assert clicking "View details" sets URL to `?sidePath=view&profile=` instead of asserting a panel heading appears. + +### `PublicationsPage.test.tsx` +- Panel renders when URL has `?sidePath=view&profile=`. +- Panel is closed by default (no `sidePath`). +- Closing calls `createPageParamsSetter` clearing both params. + +### `PublicationDetailsSidePanel.test.tsx` +- Renders `PublicationDetails` when publication is loaded. +- Renders loading state while `isGettingPublication` is true. + +No new MSW handlers needed. + +--- + +## 6. Files Summary + +| File | Change | +|------|--------| +| `src/features/publication-targets/components/PublicationsTable/PublicationsTable.tsx` | Label column → `` Cell renderer | +| `src/features/publications/components/PublicationsList/PublicationsList.tsx` | `setSidePanelContent` → `createPageParamsSetter` | +| `src/features/publications/components/PublicationsListActions/PublicationsListActions.tsx` | `setSidePanelContent` → `createPageParamsSetter` | +| `src/features/publications/api/useGetPagePublication.ts` | **New** | +| `src/features/publications/components/PublicationDetailsSidePanel/PublicationDetailsSidePanel.tsx` | **New** | +| `src/features/publications/index.ts` | Export `PublicationDetailsSidePanel` | +| `src/pages/dashboard/repositories/publications/PublicationsPage.tsx` | Add `` with `sidePath` routing | diff --git a/.github/feature-plans/publication-targets.md b/.github/feature-plans/publication-targets.md new file mode 100644 index 000000000..df4daf619 --- /dev/null +++ b/.github/feature-plans/publication-targets.md @@ -0,0 +1,694 @@ +# Implementation Plan: `publication-targets` + +## 1. Feature Overview + +- **Objective:** Add a Publication Targets page nested under the Repositories section, enabling users to list, create, edit, and delete publication targets (S3 or Swift storage destinations). Routes to `/repositories/publication-targets` using the existing `REPOSITORIES_ROUTES.publicationTargets` route. +- **Location:** `src/features/publication-targets/` + +> **Status:** Core implementation is complete. The page is functional with list, add, edit, remove, and details flows. Swift edit support is pending (EditTargetForm is currently S3-only). + +--- + +## 2. API Design + +The backend is a Connect-RPC (gRPC-JSON transcoding) service. All calls go through the v2 API base URL (`VITE_API_URL` → `/api/v2/`). Endpoint paths follow the resource name pattern `publicationTargets/{id}`. + +### Endpoints + +Proto HTTP transcoding (`body: "publication_target"`) means POST and PATCH bodies are the `PublicationTarget` message directly — **not** nested under a wrapper key. + +| Method | Path | Body | Purpose | +|---|---|---|---| +| `GET` | `publicationTargets` | — | List all publication targets (response includes embedded publications via `PublicationTargetWithPublications`) | +| `POST` | `publicationTargets` | `PublicationTarget` fields (flat) | Create a new publication target | +| `GET` | `publicationTargets/{id}` | — | Get a single publication target | +| `PATCH` | `publicationTargets/{id}` | `PublicationTarget` fields + `name` (flat) | Update a publication target | +| `DELETE` | `publicationTargets/{id}` | — | Delete a publication target | + +`{id}` is the UUID segment of the resource `name` (e.g. `name = "publicationTargets/uuid"` → path `publicationTargets/uuid`). + +> **Note:** There is no separate `GET publications` endpoint usage. Publications are embedded in the `GET publicationTargets` response as `publications: Publication[]` on each target object. The `PublicationTargetWithPublications` extends `PublicationTarget` with this field. + +### TypeScript Interfaces (`src/features/publication-targets/types/`) + +**`PublicationTarget.d.ts`** +```ts +export interface S3Target { + region: string; + bucket: string; + endpoint?: string; + aws_access_key_id: string; + aws_secret_access_key: string; + prefix?: string; + acl?: string; + storage_class?: string; + encryption_method?: string; + plus_workaround?: boolean; + disable_multi_del?: boolean; + force_sig_v2?: boolean; + debug?: boolean; +} + +export interface SwiftTarget { + container: string; + username: string; + password: string; + prefix?: string; + auth_url?: string; + tenant?: string; + tenant_id?: string; + domain?: string; + domain_id?: string; + tenant_domain?: string; + tenant_domain_id?: string; +} + +export interface PublicationTarget extends Record { + name: string; // e.g. "publicationTargets/{uuid}" + publication_target_id: string; // UUID + display_name: string; + s3?: S3Target; + swift?: SwiftTarget; +} +``` + +**`Publication.d.ts`** +```ts +import type { PublicationTarget } from "./PublicationTarget"; + +export interface Publication extends Record { + name: string; + publication_id: string; + display_name: string; + publication_target: string; // resource name, e.g. "publicationTargets/{uuid}" + mirror?: string; // resource name of the source mirror + distribution?: string; +} + +export interface PublicationTargetWithPublications extends PublicationTarget { + publications: Publication[]; +} +``` + +### Hooks (`src/features/publication-targets/hooks/`) + +**[Implemented] `usePublicationTargets` (`usePublicationTargets.tsx`)** +Main consolidated CRUD hook using the `QueryFnType` pattern (same pattern as `useRepositoryProfiles`). Returned queries/mutations: + +- `getPublicationTargetsQuery(params?, config?)` — `GET publicationTargets`; response: `{ publication_targets: PublicationTargetWithPublications[] }` +- `createPublicationTargetQuery` — `POST publicationTargets`; body: flat `PublicationTarget` fields +- `editPublicationTargetQuery` — `PATCH publicationTargets/{name}`; body: flat fields including `name` +- `removePublicationTargetQuery` — `DELETE publicationTargets/{name}` + +All mutations invalidate `["publicationTargets"]` on success. + +> **Note:** There is no `useGetPublications` hook. Publications are embedded in the target response and typed via `PublicationTargetWithPublications`. + +--- + +## 3. Component Hierarchy + +``` +src/features/publication-targets/ +├── hooks/ +│ ├── index.ts # exports usePublicationTargets +│ ├── usePublicationTargets.tsx # ✅ full CRUD; QueryFnType pattern +│ ├── useGetPublicationTargets.tsx # ⚠️ standalone simple hook (may be redundant) +│ ├── useCreatePublicationTarget.tsx # ⚠️ standalone mutation (may be redundant) +│ └── useDeleteTargetModal.tsx # 🗑️ dead code; safe to delete +├── components/ +│ ├── PublicationTargetContainer/ +│ │ └── PublicationTargetContainer.tsx # ✅ Routes to empty state or PublicationTargetList +│ ├── PublicationTargetList/ +│ │ └── PublicationTargetList.tsx # ✅ Table; columns: Name, Type, Publications count, Actions +│ ├── PublicationTargetListActions/ +│ │ └── PublicationTargetListActions.tsx # ✅ 3-dot ListActions; View details / Edit / Remove +│ ├── TargetDetails/ +│ │ └── TargetDetails.tsx # ✅ Side panel details view; Edit + Remove buttons at top; +│ │ # DETAILS section (InfoGrid); USED IN section (PublicationsTable) +│ ├── PublicationsTable/ +│ │ └── PublicationsTable.tsx # ✅ Shared table: Publication / Source / Distribution; +│ │ # optional pageSize prop enables SidePanelTablePagination +│ ├── NewPublicationTargetForm/ +│ │ ├── NewPublicationTargetForm.tsx # ✅ Formik form; S3 and Swift fields +│ │ └── constants.ts # ✅ INITIAL_VALUES, VALIDATION_SCHEMA +│ ├── EditTargetForm/ +│ │ └── EditTargetForm.tsx # ✅ Pre-populated Formik form; ⚠️ currently S3-only +│ ├── RemoveTargetForm/ +│ │ └── RemoveTargetForm.tsx # ✅ Side panel confirmation form; shows PublicationsTable +│ │ # of associated publications; Cancel + Remove buttons +│ └── PublicationTargetAddButton/ +│ └── PublicationTargetAddButton.tsx # ✅ Button; opens NewPublicationTargetForm in side panel +├── types/ +│ ├── PublicationTarget.d.ts +│ ├── Publication.d.ts # also defines PublicationTargetWithPublications +│ └── index.d.ts +└── index.ts # Barrel exports +``` + +### Table Columns (`PublicationTargetList`) + +| Column | Source | Notes | +|---|---|---| +| Name | `display_name` | Plain text | +| Type | derived from presence of `s3` / `swift` key | `"S3"`, `"Swift"`, or `"—"` | +| Publications | `target.publications.length` | e.g. `"2"` from embedded array | +| Actions | `PublicationTargetListActions` | 3-dot menu via `ListActions` component | + +### Actions (`PublicationTargetListActions`) + +All three actions open the **right-side side panel** via `setSidePanelContent` — no modals are used. + +| Action | Opens | +|---|---| +| **View details** | `TargetDetails` side panel | +| **Edit** | `EditTargetForm` side panel | +| **Remove** | `RemoveTargetForm` side panel | + +> **Design decision:** Original plan specified modals for details and removal. Implementation uses side panels exclusively for consistency with the rest of the application. `TargetDetails` also exposes Edit and Remove buttons that open the same sub-panels. + +### `TargetDetails` Layout + +1. Edit and Remove buttons (segmented control at top) +2. `
` divider +3. **DETAILS** section via `InfoGrid` — Name in full row; S3 or Swift fields in two-column grid +4. **USED IN** section — `PublicationsTable` (no pagination in details view; full list) + +### `RemoveTargetForm` Layout + +1. `
` divider at top +2. If target has associated publications: explanatory text + `PublicationsTable` with `pageSize={5}` +3. Warning text ("This action is irreversible") +4. `
` divider +5. Right-aligned button row: Cancel + Remove target (negative, delete icon) + +--- + +## 4. State & Logic + +### Forms + +All forms use Formik + Yup. + +#### `NewPublicationTargetForm` — `INITIAL_VALUES` and `VALIDATION_SCHEMA` + +See `src/features/publication-targets/components/NewPublicationTargetForm/constants.ts`. + +Required S3 fields: `region`, `bucket`, `aws_access_key_id`, `aws_secret_access_key`. +Required Swift fields: `container`, `username`, `password`. + +#### `EditTargetForm` — current state + +Pre-populated from the target passed as prop. Currently **S3-only** — Swift edit fields are not yet implemented. The form submits a `PATCH` with the full S3 payload. + +> **Pending work:** Add Swift field support to `EditTargetForm`, matching the structure of `NewPublicationTargetForm`. + +### Publications Data + +Publications are **not fetched separately**. They arrive embedded in `GET publicationTargets` as `publications: Publication[]` on each target. `PublicationTargetWithPublications` (from `types/Publication.d.ts`) is the runtime type used throughout components. + +The `PublicationsPage.tsx` maps the raw response: +```ts +const targets = ( + publicationTargetsResult.data?.data.publication_targets ?? [] +).map((target) => ({ ...target, publications: target.publications ?? [] })); +``` + +### Global Context + +No new context. Uses `useSidePanel()`, `useNotify()`, `useDebug()` from shared hooks. + +--- + +## 5. Testing Status + +### MSW Handlers (`src/tests/server/handlers/publicationTargets.ts`) + +| Endpoint | Status | +|---|---| +| `GET publicationTargets` | ✅ Returns `publicationTargetsWithPublications` (3 targets; prod-s3-us-east has 3 publications) | +| `POST publicationTargets` | ✅ Returns 201 with generated name/id | +| `DELETE publicationTargets/:id` | ✅ Returns 204 | +| `PATCH publicationTargets/:id` | ✅ Returns updated target body | + +### Mock Data (`src/tests/mocks/publication-targets.ts`) + +Exports: +- `publications` — 3 `Publication` objects (Jammy, Noble, Focal), all linked to `prod-s3-us-east` +- `publicationTargets` — 3 `PublicationTarget` objects (2 S3, 1 Swift) +- `publicationTargetsWithPublications` — same 3 targets with embedded publications + +### Unit Tests + +| File | Status | +|---|---| +| `PublicationTargetListActions.test.tsx` | ✅ Exists | +| All other component/hook tests | ❌ Not yet written | + +> **Pending work:** Unit tests for hooks, `PublicationTargetList`, `TargetDetails`, `NewPublicationTargetForm`, `EditTargetForm`, `RemoveTargetForm`, and `PublicationsTable`. + +--- + +## 6. Routing & Navigation (Already Wired) + +The following are already in place and **must not be changed**: + +- Route path: `REPOSITORIES_PATHS.publicationTargets = "publication-targets"` in `src/libs/routes/repositories.ts` +- Route entry: `REPOSITORIES_ROUTES.publicationTargets` (same file) +- Lazy page load: `PublicationTargetsPage` in `src/routes/elements.tsx` +- Dashboard route: `src/routes/DashboardRoutes.tsx` +- Navigation link: `src/templates/dashboard/Navigation/constants.ts` + +--- + +## 7. Pending Work + +| Item | Priority | Notes | +|---|---|---| +| Swift edit support in `EditTargetForm` | High | Currently S3-only; add Swift field block matching `NewPublicationTargetForm` | +| Unit tests (all components and hooks) | High | See testing plan in original spec; `PublicationTargetListActions.test.tsx` exists | +| Delete `useDeleteTargetModal.tsx` | Low | Dead code; replaced by `RemoveTargetForm` | +| Consolidate/remove `useGetPublicationTargets` and `useCreatePublicationTarget` | Low | Redundant with `usePublicationTargets`; remove after confirming no references | + +--- + +## 8. Implementation Notes / Decisions + +| Decision | Rationale | +|---|---| +| Side panels instead of modals for details/removal | Consistent with application-wide pattern in other features (scripts, mirrors, etc.) | +| Publications embedded in target response | Avoids a secondary `GET publications` request; backend already returns associated publications with each target | +| Single `usePublicationTargets` hook for all CRUD | Follows `useRepositoryProfiles` pattern; `QueryFnType` enables param passing while keeping the hook composable | +| `PublicationsTable` shared component | Both `TargetDetails` and `RemoveTargetForm` render the same 3-column publications table; extracted to avoid duplication | +| `PublicationTargetContainer` added | Separates data-fetching concerns from the page; mirrors pattern in other feature areas | + + +--- + +## 2. API Design + +The backend is a Connect-RPC (gRPC-JSON transcoding) service. All calls go through the v2 API base URL (`VITE_API_URL` → `/api/v2/`). Endpoint paths follow the resource name pattern `publicationTargets/{id}`. + +### Endpoints + +Proto HTTP transcoding (`body: "publication_target"`) means POST and PATCH bodies are the `PublicationTarget` message directly — **not** nested under a wrapper key. + +| Method | Path | Body | Purpose | +|---|---|---|---| +| `GET` | `publicationTargets` | — | List all publication targets | +| `POST` | `publicationTargets` | `PublicationTarget` fields (flat) | Create a new publication target | +| `GET` | `publicationTargets/{id}` | — | Get a single publication target | +| `PATCH` | `publicationTargets/{id}` | `PublicationTarget` fields + `name` (flat) | Update a publication target | +| `DELETE` | `publicationTargets/{id}` | — | Delete a publication target | + +`{id}` is the UUID segment of the resource `name` (e.g. `name = "publicationTargets/uuid"` → path `publicationTargets/uuid`). + +### TypeScript Interfaces (to be placed in `src/features/publication-targets/types/`) + +```ts +// PublicationTarget.d.ts + +export interface S3Target { + region: string; + bucket: string; + endpoint?: string; + aws_access_key_id: string; + aws_secret_access_key: string; + prefix?: string; + acl?: string; + storage_class?: string; + encryption_method?: string; + plus_workaround?: boolean; + disable_multi_del?: boolean; + force_sig_v2?: boolean; + debug?: boolean; +} + +export interface SwiftTarget { + container: string; + username: string; + password: string; + prefix?: string; + auth_url?: string; // not server-validated as required (proto buf validate only checks container/username/password) + tenant?: string; + tenant_id?: string; + domain?: string; + domain_id?: string; + tenant_domain?: string; + tenant_domain_id?: string; +} + +export interface PublicationTarget { + name: string; // e.g. "publicationTargets/{uuid}" + publication_target_id: string; // UUID + display_name: string; + s3?: S3Target; + swift?: SwiftTarget; +} +``` + +```ts +// index.d.ts +export type { PublicationTarget, S3Target, SwiftTarget } from "./PublicationTarget"; +``` + +### Hooks to Create (`src/features/publication-targets/hooks/`) + +#### `useGetPublicationTargets` (`useGetPublicationTargets.ts`) + +```ts +// Returns: +{ + publicationTargets: PublicationTarget[]; + isPublicationTargetsLoading: boolean; +} +// queryKey: ["publicationTargets"] +// queryFn: authFetch.get("publicationTargets") +// Response shape: { publication_targets: PublicationTarget[] } +``` + +#### `useCreatePublicationTarget` (`useCreatePublicationTarget.ts`) + +```ts +// Mutation params (body sent flat — proto body: "publication_target" transcoding): +{ + display_name: string; + s3?: S3Target; // exactly one of s3 / swift must be set + swift?: SwiftTarget; +} +// POST publicationTargets +// On success: invalidate ["publicationTargets"] +``` + +#### `useUpdatePublicationTarget` (`useUpdatePublicationTarget.ts`) + +```ts +// Mutation params (body sent flat — proto body: "publication_target" transcoding): +{ + name: string; // full resource name: "publicationTargets/{uuid}" + display_name?: string; + s3?: S3Target; + swift?: SwiftTarget; + update_mask?: string; // optional field mask e.g. "display_name,s3" +} +// PATCH publicationTargets/{uuid} (uuid extracted from name) +// Axios call: authFetch.patch(name, { name, display_name?, s3?, swift?, update_mask? }) +// On success: invalidate ["publicationTargets"] +``` + +#### `useDeletePublicationTarget` (`useDeletePublicationTarget.ts`) + +```ts +// Mutation params: +{ name: string } // full resource name: "publicationTargets/{uuid}" +// DELETE publicationTargets/{uuid} (Axios: authFetch.delete(name)) +// On success: invalidate ["publicationTargets"] +``` + +#### `useGetPublications` (`useGetPublications.ts`) + +```ts +// Used by RemovePublicationTargetModal to display which publications reference a target. +// Returns: +{ + publications: Publication[]; + isPublicationsLoading: boolean; +} +// queryKey: ["publications"] +// queryFn: authFetch.get("publications") +// Response shape: { publications: Publication[] } +// Filter client-side: publications.filter(p => p.publication_target === target.name) +``` + +All hooks use `useFetch` (v2 API). The hook index at `src/features/publication-targets/hooks/index.ts` re-exports all hooks. + +### Pagination Strategy + +Follow the mirrors pattern: **fetch all items in a single request** (no offset/cursor pagination). The `ListPublicationTargetsRequest` proto supports `page_size` / `page_token` but publication targets are a small, bounded dataset. Do not wire up `usePageParams` for this feature. + +--- + +## 3. Component Hierarchy + +``` +src/features/publication-targets/ +├── hooks/ +│ ├── index.ts +│ ├── useGetPublicationTargets.ts +│ ├── useGetPublications.ts # Used by RemovePublicationTargetModal; may be replaced by a shared publications hook later +│ ├── useCreatePublicationTarget.ts +│ ├── useUpdatePublicationTarget.ts +│ └── useDeletePublicationTarget.ts +├── components/ +│ ├── PublicationTargetList/ +│ │ └── PublicationTargetList.tsx # ResponsiveTable; columns: display_name, type, publications count, actions +│ ├── PublicationTargetListActions/ +│ │ └── PublicationTargetListActions.tsx # ContextualMenu (3-dot) — items: View details, Edit, Remove +│ ├── PublicationTargetDetailsModal/ +│ │ └── PublicationTargetDetailsModal.tsx # Read-only modal showing all fields for a target (S3 or Swift) +│ ├── RemovePublicationTargetModal/ +│ │ └── RemovePublicationTargetModal.tsx # Confirmation modal: "Remove [name]" + publications currently using target +│ ├── NewPublicationTargetForm/ +│ │ ├── NewPublicationTargetForm.tsx # Formik form; target type selector (S3/Swift/file system) switches sub-fields +│ │ ├── S3Fields.tsx # Input group for S3Target fields +│ │ ├── SwiftFields.tsx # Input group for SwiftTarget fields +│ │ └── constants.ts # INITIAL_VALUES, VALIDATION_SCHEMA +│ ├── EditPublicationTargetForm/ +│ │ ├── EditPublicationTargetForm.tsx # Same sub-field layout; target type shown read-only (locked after creation) +│ │ └── constants.ts # VALIDATION_SCHEMA for edit +│ └── PublicationTargetAddButton/ +│ └── PublicationTargetAddButton.tsx # Button that opens side panel with NewPublicationTargetForm +├── types/ +│ ├── PublicationTarget.d.ts +│ ├── FormTypes.d.ts # NewPublicationTargetFormValues, EditPublicationTargetFormValues +│ └── index.d.ts +├── constants.ts # TARGET_TYPE_OPTIONS, TARGET_TYPE_LABELS +└── index.ts # Public barrel exports +``` + +### Table Columns + +| Column | Source | Notes | +|---|---|---| +| Name | `display_name` | Plain text | +| Type | derived from presence of `s3` / `swift` key | Rendered via `TARGET_TYPE_LABELS` — `"S3"`, `"Swift"`, or `"File system"` | +| Publications | count of `publications` whose `publication_target === target.name` | e.g. `"2 publications"`; data from `useGetPublications` | +| Actions | `PublicationTargetListActions` | `ContextualMenu` from `@canonical/react-components` — 3-dot trigger | + +> **Note:** `"File system"` is a future type. The current proto only defines `s3` and `swift` in the `target` oneof. Add a `filesystem` placeholder to `TARGET_TYPE_OPTIONS` but do not render `FileSystemFields` until the proto is extended. + +### Actions Menu Items + +- **View details** — opens `PublicationTargetDetailsModal` (full field dump, read-only) +- **Edit** — opens side panel with `EditPublicationTargetForm` (pre-populated; target type shown as ``** — changing target type after creation is not supported. + +### Global Context + +No new context needed. Default: **No**. + +### Side Panel + +Use `useSidePanel()` to open `NewPublicationTargetForm` and `EditPublicationTargetForm` in the application's existing side panel. Close via `closeSidePanel()` on successful mutation. + +--- + +## 5. Testing Plan + +### Unit Tests (`*.test.tsx` alongside source) + +| File | What to test | +|---|---| +| `hooks/useGetPublicationTargets.test.ts` | Returns `publicationTargets` array and `isPublicationTargetsLoading`; handles empty list | +| `hooks/useGetPublications.test.ts` | Returns `publications` array; client-side filter by `publication_target` name works | +| `hooks/useCreatePublicationTarget.test.ts` | Calls `POST publicationTargets`; invalidates query key on success | +| `hooks/useUpdatePublicationTarget.test.ts` | Calls `PATCH publicationTargets/{name}`; invalidates query key on success | +| `hooks/useDeletePublicationTarget.test.ts` | Calls `DELETE publicationTargets/{name}`; invalidates query key on success | +| `components/PublicationTargetList/PublicationTargetList.test.tsx` | Renders correct columns (name, type label, publications count); opens 3-dot menu | +| `components/PublicationTargetListActions/PublicationTargetListActions.test.tsx` | View details / Edit / Remove items each fire the correct callback | +| `components/PublicationTargetDetailsModal/PublicationTargetDetailsModal.test.tsx` | Renders all fields for an S3 target; renders all fields for a Swift target | +| `components/RemovePublicationTargetModal/RemovePublicationTargetModal.test.tsx` | Shows publication list when target is in use; shows plain confirm when unused; calls delete on confirm | +| `components/NewPublicationTargetForm/NewPublicationTargetForm.test.tsx` | Shows S3 fields by default; switches to Swift fields; validates required proto fields; submits correctly | +| `components/EditPublicationTargetForm/EditPublicationTargetForm.test.tsx` | Pre-populates from existing target; target type selector is disabled; submits PATCH on save | +| `PublicationTargetsPage.test.tsx` | Loading state; empty state; list rendered; add button opens side panel; details modal opens; remove modal opens | + +### MSW Handlers (`src/tests/mocks/`) + +Create `publicationTargets.ts` and `publications.ts` (if not already present) and register them in the handler index: + +```ts +// GET /api/v2/publicationTargets +http.get(`${API_URL}publicationTargets`, () => + HttpResponse.json({ + publication_targets: [ + { + name: "publicationTargets/00000000-0000-0000-0000-000000000001", + publication_target_id: "00000000-0000-0000-0000-000000000001", + display_name: "My S3 Bucket", + s3: { + region: "us-east-1", + bucket: "my-bucket", + aws_access_key_id: "AKIA...", + aws_secret_access_key: "secret", + }, + }, + ], + }), +); + +// POST /api/v2/publicationTargets +http.post(`${API_URL}publicationTargets`, () => + HttpResponse.json({ name: "publicationTargets/new-uuid", publication_target_id: "new-uuid", display_name: "New Target" }, { status: 201 }), +); + +// PATCH /api/v2/publicationTargets/:id (body is flat PublicationTarget fields) +http.patch(`${API_URL}publicationTargets/:id`, () => + HttpResponse.json({ name: "publicationTargets/some-uuid", publication_target_id: "some-uuid", display_name: "Updated Target" }), +); + +// DELETE /api/v2/publicationTargets/:id (:id is the UUID, matching resource name "publicationTargets/{id}") +http.delete(`${API_URL}publicationTargets/:id`, () => + new HttpResponse(null, { status: 200 }), +); + +// GET /api/v2/publications (used by RemovePublicationTargetModal) +http.get(`${API_URL}publications`, () => + HttpResponse.json({ + publications: [ + { + name: "publications/00000000-0000-0000-0000-000000000010", + publication_id: "00000000-0000-0000-0000-000000000010", + display_name: "My Publication", + publication_target: "publicationTargets/00000000-0000-0000-0000-000000000001", + mirror: "mirrors/some-mirror", + }, + ], + }), +); +``` + +--- + +## 6. Routing & Navigation (Already Wired) + +The following are already in place and **must not be changed**: + +- Route path: `REPOSITORIES_PATHS.publicationTargets = "publication-targets"` in `src/libs/routes/repositories.ts` +- Route entry: `REPOSITORIES_ROUTES.publicationTargets` (same file) +- Lazy page load: `PublicationTargetsPage` in `src/routes/elements.tsx` (line 95–96) +- Dashboard route: `src/routes/DashboardRoutes.tsx` (lines 102–103) +- Navigation link: `src/templates/dashboard/Navigation/constants.ts` (line 30) + +--- + +## 7. `constants.ts` Reference + +```ts +export const TARGET_TYPE_OPTIONS = [ + { label: "S3", value: "s3" }, + { label: "Swift", value: "swift" }, + { label: "File system", value: "filesystem" }, // placeholder — not yet in proto +] as const; + +export const TARGET_TYPE_LABELS: Record = { + s3: "S3", + swift: "Swift", + filesystem: "File system", +}; +``` + +--- + +## 8. Implementation Order + +1. Define types in `src/features/publication-targets/types/` +2. Implement `useGetPublicationTargets` + `useGetPublications` hooks +3. Implement remaining mutation hooks (`useCreatePublicationTarget`, `useUpdatePublicationTarget`, `useDeletePublicationTarget`) +4. Implement `PublicationTargetList` + `PublicationTargetListActions` (3-dot `ContextualMenu`) +5. Implement `PublicationTargetDetailsModal` +6. Implement `RemovePublicationTargetModal` +7. Implement `NewPublicationTargetForm` (with `S3Fields`, `SwiftFields`, `constants.ts`) +8. Implement `EditPublicationTargetForm` (locked target type) +9. Implement `PublicationTargetAddButton` +10. Create `src/features/publication-targets/index.ts` barrel +11. Replace body of `PublicationTargetsPage.tsx` +12. Add MSW handlers + write unit tests diff --git a/.github/feature-plans/publications-context.md b/.github/feature-plans/publications-context.md new file mode 100644 index 000000000..e2581673d --- /dev/null +++ b/.github/feature-plans/publications-context.md @@ -0,0 +1,455 @@ +### pulication_target.proto + +syntax = "proto3"; + +package canonical.landscape.debarchive.v1; + +import "buf/validate/validate.proto"; +import "canonical/landscape/debarchive/v1/publication.proto"; +import "canonical/landscape/debarchive/v1/task.proto"; +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/field_mask.proto"; + +// PublicationService manages the publication of Debian artifacts. +service PublicationService { + // CreatePublication creates a new Publication. + rpc CreatePublication(CreatePublicationRequest) returns (Publication) { + option (google.api.http) = { + post: "/v1/publications" + body: "publication" + }; + option (google.api.method_signature) = "publication,publication_id"; + } + + // GetPublication retrieves a Publication by its resource name. + rpc GetPublication(GetPublicationRequest) returns (Publication) { + option (google.api.http) = {get: "/v1/{name=publications/*}"}; + option (google.api.method_signature) = "name"; + } + + // ListPublications returns a list of Publications. + rpc ListPublications(ListPublicationsRequest) returns (ListPublicationsResponse) { + option (google.api.http) = {get: "/v1/publications"}; + option (google.api.method_signature) = ""; + } + + // UpdatePublication updates the details of a Publication. + rpc UpdatePublication(UpdatePublicationRequest) returns (Publication) { + option (google.api.http) = { + patch: "/v1/{publication.name=publications/*}" + body: "publication" + }; + option (google.api.method_signature) = "publication,update_mask"; + } + + // DeletePublication deletes a Publication. + // Note: This operation may trigger a background Task to clean up published artifacts. + rpc DeletePublication(DeletePublicationRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1/{name=publications/*}"}; + option (google.api.method_signature) = "name"; + } + + // PublishPublication triggers the actual publication process. + // This is a custom method that returns a background Task. + rpc PublishPublication(PublishPublicationRequest) returns (PublishPublicationResponse) { + option (google.api.http) = { + post: "/v1/{name=publications/*}:publish" + body: "*" + }; + option (google.api.method_signature) = "name"; + } +} + +// CreatePublicationRequest is the request message for CreatePublication. +message CreatePublicationRequest { + // The ID to use for the publication (optional). + string publication_id = 1 [(google.api.field_behavior) = OPTIONAL]; + + // The publication to create. + Publication publication = 2 [ + (google.api.field_behavior) = REQUIRED, + + (buf.validate.field).required = true, + + (buf.validate.field).cel = { + id: "publication_target.required" + message: "publication target is required" + expression: "this.publication_target.size() > 0" + }, + + (buf.validate.field).cel = { + id: "mirror.required" + message: "mirror is required" + expression: "this.mirror.size() > 0" + } + ]; +} + +// GetPublicationRequest is the request message for GetPublication. +message GetPublicationRequest { + // The resource name of the publication to retrieve. + // Format: "publications/{publication}" + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/Publication"}, + (buf.validate.field).required = true + ]; +} + +// UpdatePublicationRequest is the request message for UpdatePublication. +message UpdatePublicationRequest { + // The publication to update. + Publication publication = 1 [ + (google.api.field_behavior) = REQUIRED, + + (buf.validate.field).required = true, + + (buf.validate.field).cel = { + id: "name.required" + message: "name is required" + expression: "this.name.size() > 0" + } + ]; + + // The list of fields to update. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// DeletePublicationRequest is the request message for DeletePublication. +message DeletePublicationRequest { + // The resource name of the publication to delete. + // Format: "publications/{publication}" + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/Publication"}, + (buf.validate.field).required = true + ]; +} + +// ListPublicationsRequest is the request message for ListPublications. +message ListPublicationsRequest { + // The maximum number of publications to return. + // The service may return fewer than this value. + // If unspecified, at most 100 publications will be returned. + // The maximum value is 1000; values above 1000 will be coerced to 1000. + int32 page_size = 1 [(google.api.field_behavior) = OPTIONAL]; + + // A page token, received from a previous `ListPublication` call. + // Provide this to retrieve the subsequent page. + // + // When paginating, all other parameters provided to `ListPublication` must match + // the call that provided the page token. + string page_token = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// ListPublicationsResponse is the response message for ListPublications. +message ListPublicationsResponse { + // A list of publications. + repeated Publication publications = 1; + + // A token, which can be sent as `page_token` to retrieve the next page. + // If this field is omitted, there are no subsequent pages. + string next_page_token = 2; +} + +// PublishPublicationRequest is the request message for PublishPublication. +message PublishPublicationRequest { + // The resource name of the publication to publish. + // Format: "publications/{publication}" + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/Publication"}, + (buf.validate.field).required = true + ]; + + // Force overwrite of existing artifacts. + bool force_overwrite = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Force cleanup of old artifacts. + bool force_cleanup = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// PublishPublicationResponse is the response message for PublishPublication. +message PublishPublicationResponse { + // The resulting task from a sync mirror. + Task task = 1; +} + + +### pulication_target_service.proto +syntax = "proto3"; + +package canonical.landscape.debarchive.v1; + +import "buf/validate/validate.proto"; +import "canonical/landscape/debarchive/v1/publication_target.proto"; +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/field_mask.proto"; + +// PublicationTargetService manages the storage destinations for Debian archives. +service PublicationTargetService { + // CreatePublicationTarget creates a new PublicationTarget. + rpc CreatePublicationTarget(CreatePublicationTargetRequest) returns (PublicationTarget) { + option (google.api.http) = { + post: "/v1/publicationTargets" + body: "publication_target" + }; + option (google.api.method_signature) = "publication_target,publication_target_id"; + } + + // GetPublicationTarget gets details of a single PublicationTarget. + rpc GetPublicationTarget(GetPublicationTargetRequest) returns (PublicationTarget) { + option (google.api.http) = {get: "/v1/{name=publicationTargets/*}"}; + option (google.api.method_signature) = "name"; + } + + // ListPublicationTargets lists all PublicationTargets. + rpc ListPublicationTargets(ListPublicationTargetsRequest) returns (ListPublicationTargetsResponse) { + option (google.api.http) = {get: "/v1/publicationTargets"}; + option (google.api.method_signature) = ""; + } + + // UpdatePublicationTarget replaces (full replacement) an existing PublicationTarget. + rpc UpdatePublicationTarget(UpdatePublicationTargetRequest) returns (PublicationTarget) { + option (google.api.http) = { + patch: "/v1/{publication_target.name=publicationTargets/*}" + body: "publication_target" + }; + option (google.api.method_signature) = "publication_target,update_mask"; + } + + // DeletePublicationTarget deletes a PublicationTarget. + rpc DeletePublicationTarget(DeletePublicationTargetRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1/{name=publicationTargets/*}"}; + option (google.api.method_signature) = "name"; + } +} + +// CreatePublicationTargetRequest is the request message for CreatePublicationTarget. +message CreatePublicationTargetRequest { + // The ID to use for the publication target (optional). + // If not provided, the server will generate one. + string publication_target_id = 1 [(google.api.field_behavior) = OPTIONAL]; + + // The resource to create. + PublicationTarget publication_target = 2 [ + (google.api.field_behavior) = REQUIRED, + + (buf.validate.field).required = true, + + (buf.validate.field).cel = { + id: "display_name.required" + message: "display name is required" + expression: "this.display_name.size() > 0" + }, + + (buf.validate.field).cel = { + id: "target.required" + message: "target is required" + expression: "has(this.s3) || has(this.swift)" + }, + + (buf.validate.field).cel = { + id: "s3.region.required" + message: "s3 region is required" + expression: "has(this.s3) ? this.s3.region.size() > 0 : true" + }, + + (buf.validate.field).cel = { + id: "s3.bucket.required" + message: "s3 bucket is required" + expression: "has(this.s3) ? this.s3.bucket.size() > 0 : true" + }, + + (buf.validate.field).cel = { + id: "s3.aws_access_key_id.required" + message: "s3 aws access key id is required" + expression: "has(this.s3) ? this.s3.aws_access_key_id.size() > 0 : true" + }, + + (buf.validate.field).cel = { + id: "s3.aws_secret_access_key.required" + message: "s3 aws secret access key is required" + expression: "has(this.s3) ? this.s3.aws_secret_access_key.size() > 0 : true" + }, + + (buf.validate.field).cel = { + id: "swift.container.required" + message: "swift container name is required" + expression: "has(this.swift) ? this.swift.container.size() > 0 : true" + }, + + (buf.validate.field).cel = { + id: "swift.username.required" + message: "swift username is required" + expression: "has(this.swift) ? this.swift.username.size() > 0 : true" + }, + + (buf.validate.field).cel = { + id: "swift.password.required" + message: "swift password is required" + expression: "has(this.swift) ? this.swift.password.size() > 0 : true" + } + ]; +} + +// GetPublicationTargetRequest is the request message for GetPublicationTarget. +message GetPublicationTargetRequest { + // The name of the publication target to retrieve. + // Format: "publicationTargets/{publication_target}" + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/PublicationTarget"}, + (buf.validate.field).required = true + ]; +} + +// ListPublicationTargetsRequest is the request message for ListPublicationTargets. +message ListPublicationTargetsRequest { + // The maximum number of publication targets to return. + // The service may return fewer than this value. + // If unspecified, at most 100 publication targets will be returned. + // The maximum value is 1000; values above 1000 will be coerced to 1000. + int32 page_size = 1 [(google.api.field_behavior) = OPTIONAL]; + + // A page token, received from a previous `ListPublicationTargets` call. + // Provide this to retrieve the subsequent page. + // + // When paginating, all other parameters provided to `ListPublicationTargets` must match + // the call that provided the page token. + string page_token = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// ListPublicationTargetsResponse is the response message for ListPublicationTargets. +message ListPublicationTargetsResponse { + // The publication targets. + repeated PublicationTarget publication_targets = 1; + + // A token, which can be sent as `page_token` to retrieve the next page. + // If this field is omitted, there are no subsequent pages. + string next_page_token = 2; +} + +// UpdatePublicationTargetRequest is the request message for UpdatePublicationTarget. +message UpdatePublicationTargetRequest { + // The resource to update. + PublicationTarget publication_target = 1 [ + (google.api.field_behavior) = REQUIRED, + + (buf.validate.field).required = true, + + (buf.validate.field).cel = { + id: "name.required" + message: "name is required" + expression: "this.name.size() > 0" + } + ]; + + // The list of fields to update. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// DeletePublicationTargetRequest is the request message for DeletePublicationTarget. +message DeletePublicationTargetRequest { + // The name of the publication target to delete. + // Format: "publicationTargets/{publication_target}" + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/PublicationTarget"}, + (buf.validate.field).required = true + ]; +} + +### publication.proto +syntax = "proto3"; + +package canonical.landscape.debarchive.v1; + +import "canonical/landscape/debarchive/v1/gpg_key.proto"; +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; + +// Publication represents the configuration for publishing Debian artifacts +// from a Source (Mirror) to a Target. +message Publication { + option (google.api.resource) = { + type: "landscape.canonical.com/Publication" + pattern: "publications/{publication}" + singular: "publication" + plural: "publications" + }; + + // The resource name of the publication. + // Format: "publications/{uuid}" + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // The publication ID (UUID) is exposed separately for convenience. + string publication_id = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // The resource name of the publication target. + // Format: "publicationTargets/{publication_target}" + string publication_target = 3 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/PublicationTarget"} + ]; + + // The resource name of the source mirror. + // Format: "mirrors/{mirror}" + string mirror = 4 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "landscape.canonical.com/Mirror"} + ]; + + // The name of the distribution. + // Inferred from the source if omitted. + string distribution = 5 [(google.api.field_behavior) = OPTIONAL]; + + // The name of the component to publish. + // Inferred from the source if omitted. + string component = 6 [(google.api.field_behavior) = OPTIONAL]; + + // Value for the `Label:` field in the Release file. + string label = 7 [(google.api.field_behavior) = OPTIONAL]; + + // Value for the `Origin:` field in the Release file. + // Inferred from the source if omitted. + string origin = 8 [(google.api.field_behavior) = OPTIONAL]; + + // List of architectures to publish. + // Defaults to all source architectures if omitted. + repeated string architectures = 9 [ + (google.api.field_behavior) = OPTIONAL, + (google.api.field_behavior) = UNORDERED_LIST + ]; + + // Provides index file by hash if unique. + // (-- api-linter: core::0140::prepositions=disabled --) + bool acquire_by_hash = 10 [(google.api.field_behavior) = OPTIONAL]; + + // Sets `ButAutomaticUpgrade: yes|no` in the Release file if provided. + // (-- api-linter: core::0140::prepositions=disabled --) + optional bool but_automatic_upgrades = 11 [(google.api.field_behavior) = OPTIONAL]; + + // Sets `NotAutomatic: yes|no` in the Release file if provided. + optional bool not_automatic = 12 [(google.api.field_behavior) = OPTIONAL]; + + // Indicates if multiple distributions are supported. + bool multi_dist = 13 [(google.api.field_behavior) = OPTIONAL]; + + // Indicates if bz2 compression should be skipped for index files. + bool skip_bz2 = 14 [(google.api.field_behavior) = OPTIONAL]; + + // Indicates if content indexes should not be generated. + bool skip_contents = 15 [(google.api.field_behavior) = OPTIONAL]; + + // Optional GPG key to sign the release file(s). + optional GpgKey gpg_key = 16 [(google.api.field_behavior) = OPTIONAL]; +} \ No newline at end of file diff --git a/.github/feature-plans/targets-with-publications.md b/.github/feature-plans/targets-with-publications.md new file mode 100644 index 000000000..9752e8172 --- /dev/null +++ b/.github/feature-plans/targets-with-publications.md @@ -0,0 +1,289 @@ +# Feature Plan: Lazy Publications Loading (`targets-with-publications`) + +> **Final** — standalone `useGetPublicationsByTarget`; server-side filter; no changes to `src/features/publications/`. + +--- + +## 1. Feature Overview + +- **Objective:** Replace the single eager "fetch all publications and client-side join" strategy with a per-target, on-demand, server-side filtered query. The publications count column stays in the list table. Publications data is fetched only when needed for a specific target. +- **Location:** `src/features/publication-targets/` + +--- + +## 2. Root Cause + +`useGetPublicationTargets` unconditionally calls the local `useGetPublications()` (which paginates through *all* publications) and client-side joins the result into every list row. The publications payload grows linearly with account activity and is O(total publications) regardless of how many targets are displayed. + +--- + +## 3. `src/features/publications/` — No Changes + +The existing `useGetPublications` hook is designed for the Publications list page: it is URL-params-driven (consumes `usePageParams`), applies client-side search filtering, and returns paginated results. It must not be modified as part of this refactor. + +Both `Publication` and `ListPublicationsResponse` are exported from `@/features/publications` and are available for the new hook to import. + +--- + +## 4. Changes to `src/features/publication-targets/` + +### 4.1 New Hook: `useGetPublicationsByTarget` + +**File:** `src/features/publication-targets/api/useGetPublicationsByTarget.ts` + +A standalone hook that calls the `publications` endpoint directly with a server-side `publicationTargetId` filter. It does not wrap the `publications` feature's `useGetPublications`. + +```ts +// Signature +function useGetPublicationsByTarget(publicationTargetId: string | undefined): { + publications: Publication[]; + isGettingPublications: boolean; +} +``` + +- **Endpoint:** `GET publications?publicationTargetId=` (paginated, cursor-based). +- **Query key:** `["publications", { publicationTargetId }]` — distinct per target, independently cached. +- If `publicationTargetId` is `undefined`, the query is disabled (`enabled: false`); returns `{ publications: [], isGettingPublications: false }`. +- Pagination follows the same `do { … } while (pageToken)` pattern as other hooks in this feature. +- Import sources: + - `Publication` from `@/features/publications` + - `ListPublicationsResponse` from `@/api/generated/debArchive.schemas` (not exported by `@/features/publications`) + - `useFetchDebArchive` from `@/hooks/useFetchDebArchive` + +```ts +// Query params sent to the API +{ publicationTargetId: string, pageSize: 100, pageToken?: string } +``` + +### 4.2 `useGetPublicationTargets` (MODIFY) + +**File:** `src/features/publication-targets/api/useGetPublicationTargets.ts` + +- Remove `import useGetPublications` (local hook). +- Remove `import type { PublicationTargetWithPublications }`. +- Remove the client-side join (`targets.map(...)`). +- Return plain `PublicationTarget[]`. + +```ts +// Signature after refactor +function useGetPublicationTargets(): { + publicationTargets: PublicationTarget[]; + isGettingPublicationTargets: boolean; +} +``` + +### 4.3 `useRemovePublicationTarget` (MODIFY) + +**File:** `src/features/publication-targets/api/useRemovePublicationTarget.ts` + +The `onSuccess` handler currently only invalidates `["publication-targets"]`. Add invalidation of `["publications"]` so per-target count caches are busted when a target is deleted. + +```ts +onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["publication-targets"] }), + queryClient.invalidateQueries({ queryKey: ["publications"] }), + ]); +} +``` + +### 4.4 Delete: `useGetPublications` in `publication-targets` + +**File:** `src/features/publication-targets/api/useGetPublications.ts` — **delete**. + +This was a copy of the global publications fetch used solely for the join. It has no remaining consumers after this refactor. + +### 4.5 `src/features/publication-targets/api/index.ts` + +- Remove `export { default as useGetPublications }`. +- Add `export { default as useGetPublicationsByTarget }`. + +### 4.6 Component Changes + +#### Updated Type Flow + +``` +PublicationTargetContainer → passes PublicationTarget[] + └─ PublicationTargetList → accepts PublicationTarget[] + └─ PublicationsCountCell → calls useGetPublicationsByTarget(target.publicationTargetId) + └─ PublicationTargetListActions → accepts PublicationTarget + ├─ TargetDetails → accepts PublicationTarget; calls useGetPublicationsByTarget internally + ├─ EditTargetForm → unchanged + └─ RemoveTargetForm → accepts PublicationTarget; calls useGetPublicationsByTarget internally +``` + +#### `PublicationTargetList` (MODIFY) + +- Change `PublicationTargetWithPublications` → `PublicationTarget` throughout. +- Extract a `PublicationsCountCell` named sub-component — hooks cannot be called inside `Cell` render functions. +- Publications column accessor: `(row) => row.publicationTargetId` (opaque value; count derived in cell). + +```ts +interface PublicationsCountCellProps { + publicationTargetId: string | undefined; +} +// PublicationsCountCell calls useGetPublicationsByTarget and renders: +// isGettingPublications → +// loaded, length === 0 → +// loaded, length > 0 → "{n} publication(s)" +``` + +There is no standalone `` component in the project. Use the same pattern as `LoadingState`: `` imported from `@canonical/react-components`. + +#### `PublicationTargetListActions` (MODIFY) +- Change prop type `PublicationTargetWithPublications` → `PublicationTarget`. +- Remove debug `console.log` referencing `target.publications`. + +#### `TargetDetails` (MODIFY) +- Change prop type: `target: PublicationTarget`. +- Call `useGetPublicationsByTarget(target.publicationTargetId)` in the component body. +- Replace `target.publications` with `publications` from the hook. +- Show `` in the "USED IN" section while `isGettingPublications` is `true`. + +```ts +interface TargetDetailsProps { + readonly target: PublicationTarget; +} +``` + +#### `RemoveTargetForm` (MODIFY) +- Change prop type: `target: PublicationTarget`. +- Call `useGetPublicationsByTarget(target.publicationTargetId)` in the component body. +- Replace `target.publications` with `publications` from hook. +- Gate `hasPublications` on `!isGettingPublications && publications.length > 0`. + +```ts +interface RemoveTargetFormProps { + readonly target: PublicationTarget; +} +``` + +### 4.7 Types (MODIFY) + +`src/features/publication-targets/types/Publication.d.ts`: +- Import `Publication` from `@/features/publications` instead of `@/api/generated/debArchive.schemas` directly. +- Delete `PublicationTargetWithPublications`. + +`src/features/publication-targets/types/index.d.ts`: +- Remove `PublicationTargetWithPublications` export. + +`src/features/publication-targets/index.ts`: +- Remove `PublicationTargetWithPublications` from public exports. + +--- + +## 5. Caching Behaviour + +Each unique `publicationTargetId` gets its own React Query cache entry under key `["publications", { publicationTargetId }]`. + +- All visible list rows request their counts in parallel on first render. +- Opening `TargetDetails` for target A → cache hit, zero extra network requests. +- Opening `RemoveTargetForm` for the same target → cache hit. +- On target deletion, both `["publication-targets"]` and `["publications"]` are invalidated (see §4.3). + +--- + +## 6. State & Logic + +| Location | Before | After | +|---|---|---| +| `PublicationTargetContainer` | Blocks until targets + **all** publications resolve | Blocks only until targets resolve | +| `PublicationTargetList` count cell | Synchronous (data in prop) | Per-row parallel requests; spinner icon while loading; cached on re-render | +| `TargetDetails` | Publications in prop, no loading | `` while hook resolves; cache hit on 2nd open | +| `RemoveTargetForm` | Publications in prop, no loading | Inline loading; cache hit on 2nd open | + +--- + +## 7. MSW Handlers + +**File:** `src/tests/server/handlers/publications.ts` + +Update the `GET publications` handler to read `publicationTargetId` from search params and return a filtered subset when present: + +``` +GET ${API_URL_DEBARCHIVE}v1/publications?publicationTargetId= → filtered subset +GET ${API_URL_DEBARCHIVE}v1/publications → all (unchanged) +``` + +Branch on `request.url.searchParams.get("publicationTargetId")`. + +--- + +## 8. Testing Plan + +### 8.1 Component Tests + +#### `PublicationTargetList.test.tsx` (new) +- Render with plain `PublicationTarget[]` mock data. +- Assert each row's Publications cell renders a count after the filtered MSW handler resolves. +- Assert spinner icon is shown before the query resolves. + +#### `RemoveTargetForm.test.tsx` (update) +- Change `target` prop: `publicationTargetsWithPublications[0]` → `publicationTargets[0]` (plain `PublicationTarget`). +- Behaviour identical — MSW serves filtered result by `publicationTargetId`. +- Verify publications table renders when MSW returns matching items; hidden otherwise. + +#### `TargetDetails.test.tsx` (new) +- Render ``. +- Assert S3 `InfoGrid` renders immediately (not gated on publications). +- Assert `` shown while filtered publications query is in-flight. +- Assert `PublicationsTable` renders after MSW resolves filtered publications. + +### 8.2 Hook Coverage +`useGetPublicationsByTarget` is exercised implicitly through the above component tests. No dedicated hook test file per project convention. + +### 8.3 Mocking Strategy +All component tests that internally call `useGetPublicationsByTarget` should mock it via `vi.mock` at the top of each test file. This keeps tests fast and isolated — no MSW dependency for publications data in those tests. + +--- + +## 9. Migration Checklist + +> **Status:** Implementation complete — pending `pnpm build` verification and deletion of the stale `useGetPublications.ts` file (requires terminal access). + +### Phase 1 — New `useGetPublicationsByTarget` hook +- [x] Add `src/features/publication-targets/api/useGetPublicationsByTarget.ts` +- [x] Update `src/features/publication-targets/api/index.ts` (add export) + +### Phase 2 — Strip publications join from `useGetPublicationTargets`; fix invalidation +- [x] Update `src/features/publication-targets/api/useGetPublicationTargets.ts` (remove join, change return type) +- [x] Delete `src/features/publication-targets/api/useGetPublications.ts` (**manual step** — no consumers remain; file must be deleted from disk) +- [x] Remove its export from `src/features/publication-targets/api/index.ts` +- [x] Update `src/features/publication-targets/api/useRemovePublicationTarget.ts` (add `["publications"]` invalidation to `onSuccess`) + +### Phase 3 — Component updates +- [x] Add `PublicationsCountCell` sub-component in `PublicationTargetList/` (spinner: ``) +- [x] Update `src/features/publication-targets/components/PublicationTargetList/PublicationTargetList.tsx` +- [x] Update `src/features/publication-targets/components/PublicationTargetListActions/PublicationTargetListActions.tsx` +- [x] Update `src/features/publication-targets/components/TargetDetails/TargetDetails.tsx` +- [x] Update `src/features/publication-targets/components/RemoveTargetForm/RemoveTargetForm.tsx` + +### Phase 4 — Type cleanup +- [x] Update `src/features/publication-targets/types/Publication.d.ts` (re-exports `Publication` from `@/features/publications`; `PublicationTargetWithPublications` deleted) +- [x] Update `src/features/publication-targets/types/index.d.ts` +- [x] Update `src/features/publication-targets/index.ts` + +### Phase 5 — MSW + tests +- [x] Update `src/tests/server/handlers/publications.ts` (add `publicationTargetId` filter branch; imports `publications` from `@/tests/mocks/publication-targets`) +- [x] Update `src/features/publication-targets/components/RemoveTargetForm/RemoveTargetForm.test.tsx` +- [x] Update `src/features/publication-targets/components/TargetDetails/TargetDetails.test.tsx` +- [x] Update `src/features/publication-targets/components/PublicationTargetList/PublicationTargetList.test.tsx` +- [x] Update `src/features/publication-targets/components/PublicationTargetListActions/PublicationTargetListActions.test.tsx` (prop type change) +- [x] Update `src/features/publication-targets/components/PublicationTargetContainer/PublicationTargetContainer.test.tsx` (was also using `publicationTargetsWithPublications`) +- [x] Remove `publicationTargetsWithPublications` from `src/tests/mocks/publication-targets.ts` + +--- + +## 10. Implementation Notes + +### Type resolution +`useGetPublicationsByTarget` imports `Publication` from `../types` (the local feature types, which re-exports from `@/features/publications`). `ListPublicationsResponse` comes from `@/api/generated/debArchive.schemas` as agreed. A cast `as Publication[]` bridges the schema type (optional `publicationId`) to the stricter domain type at the API boundary. + +### Import paths +All intra-feature imports of `useGetPublicationsByTarget` use relative paths (e.g. `../../api/useGetPublicationsByTarget`), not the `@/features/publication-targets/api/...` deep-import form, to comply with the ESLint import restriction rule. + +### Remaining manual step +Delete `src/features/publication-targets/api/useGetPublications.ts`. It has no remaining importers after this refactor. Run `pnpm build` after deletion to confirm clean compilation. + +### Pre-existing errors (out of scope) +`PublicationsTable.test.tsx` and `NewPublicationTargetForm/AddPublicationTargetForm.tsx` contain pre-existing type errors unrelated to this refactor. They should be tracked separately. diff --git a/.github/features/testing/repository-profiles-followup.md b/.github/features/testing/repository-profiles-followup.md new file mode 100644 index 000000000..203e6de91 --- /dev/null +++ b/.github/features/testing/repository-profiles-followup.md @@ -0,0 +1,157 @@ +# Repository Profiles — Test Coverage Follow-ups + +Outstanding test coverage tasks deferred from the initial high-priority pass. +Pick them up in any order; each item is self-contained. + +--- + +## High Priority — Broken test + +### `RepositoryProfileList` — "clicking a profile title opens the profile details side panel" + +**File:** `src/features/repository-profiles/components/RepositoryProfileList/RepositoryProfileList.test.tsx` + +**Root cause:** Clicking a profile title mounts `RepositoryProfileDetails`, which renders `AssociatedCountCell`. That cell calls `useGetProfileInstancesCount`, which fires `GET computers?query=profile:repository:`. The MSW `computers` handler in `src/tests/server/handlers/instance.ts` does not recognise this query string and throws an unhandled-request error, causing the test to fail. + +**Fix required (handler change):** + +In `src/tests/server/handlers/instance.ts`, extend the `GET computers` handler to accept a `query` param containing `"profile:repository:"` and return a normal paginated response, e.g.: + +```ts +if (query?.includes("profile:repository:")) { + return HttpResponse.json(generatePaginatedResponse([])); +} +``` + +**Test to add/fix** (already exists in the file): + +```tsx +it("clicking a profile title opens the profile details side panel", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: repositoryProfiles[0].title })); + + expect( + await screen.findByRole("heading", { name: repositoryProfiles[0].title }), + ).toBeInTheDocument(); +}); +``` + +--- + +## Medium Priority + +### `RepositoryProfileList` — `AssociatedCountCell` resolves count + +**File:** `src/features/repository-profiles/components/RepositoryProfileList/RepositoryProfileList.test.tsx` + +**Depends on:** `computers` handler fix above. + +**Test to add:** + +Assert that after the `useGetProfileInstancesCount` query resolves, each row shows the correct `applied_count` number in the Associated column. Currently `applied_count` from the mock data is rendered synchronously; verify the hook-resolved count also renders (or supersedes) it. + +--- + +### `RepositoryProfileListActions` — remove modal closes after confirmation + +**File:** `src/features/repository-profiles/components/RepositoryProfileListActions/RepositoryProfileListActions.test.tsx` + +**Test to add** (extend existing `openRemoveModal` helper): + +```tsx +it("closes the remove modal after successful confirmation", async () => { + renderWithProviders(); + await openRemoveModal(user); + + await user.type( + screen.getByRole("textbox"), + repositoryProfiles[0].name, + ); + await user.click(screen.getByRole("button", { name: /Remove/i })); + + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); +}); +``` + +--- + +### `RepositoryProfileForm` — edit mutation diff logic + +**File:** `src/features/repository-profiles/components/RepositoryProfileForm/RepositoryProfileForm.test.tsx` + +**What to test:** When editing a profile and removing an existing apt source, `editRepositoryProfileQuery` is called with the removed source's id in `remove_apt_sources` and the value absent from `add_apt_sources`. Mirror the inverse for new (id=0) sources appearing in `add_apt_sources`. + +**Approach:** Use `vi.spyOn` or wrap the MSW PUT handler to capture the request body, then assert its shape. + +--- + +## Low Priority — Accessibility + +All items below add `aria-*` assertions to existing test files; no new files are needed. + +### `RepositoryProfileList` — column cell aria-labels + +**File:** `src/features/repository-profiles/components/RepositoryProfileList/RepositoryProfileList.test.tsx` + +Assert that the title link/button, access group cell, and associated count cell carry accessible labels that screen readers can announce clearly. + +--- + +### `RepositoryProfileListActions` — keyboard navigation of actions menu + +**File:** `src/features/repository-profiles/components/RepositoryProfileListActions/RepositoryProfileListActions.test.tsx` + +Assert that the actions toggle button has an `aria-label` (e.g. `"Actions for "`), and that `{Tab}` + `{Enter}` opens the menu without a mouse click. + +--- + +### `RepositoryProfileDetails` — aria-label on Remove button + +**File:** `src/features/repository-profiles/components/RepositoryProfileDetails/RepositoryProfileDetails.test.tsx` + +Assert that the Remove button carries an accessible label (e.g. `"Remove "`), not just the generic text "Remove". + +--- + +### `RepositoryProfileForm` — form field labels and required indicators + +**File:** `src/features/repository-profiles/components/RepositoryProfileForm/RepositoryProfileForm.test.tsx` + +Assert that the submit button's accessible name matches the `CTA_INFO` label for both add and edit actions, and that required fields expose `aria-required="true"`. + +--- + +## Low Priority — Detail panel coverage + +### `RepositoryProfileFormDetailsPanel` — access group Select options + +**File:** `src/features/repository-profiles/components/RepositoryProfileFormDetailsPanel/RepositoryProfileFormDetailsPanel.test.tsx` + +Assert that each item in the `accessGroups` prop appears as a labelled `
-
+
); diff --git a/src/components/layout/ListActions/constants.ts b/src/components/layout/ListActions/constants.ts index 37a68931d..377d34bac 100644 --- a/src/components/layout/ListActions/constants.ts +++ b/src/components/layout/ListActions/constants.ts @@ -1,7 +1,6 @@ import classes from "./ListActions.module.scss"; export const LIST_ACTIONS_COLUMN_PROPS = { - accessor: "actions", className: classes.actions, disableSortBy: true, Header: "Actions", diff --git a/src/components/layout/ListTitle/constants.tsx b/src/components/layout/ListTitle/constants.tsx index e441e4ac1..96d598cff 100644 --- a/src/components/layout/ListTitle/constants.tsx +++ b/src/components/layout/ListTitle/constants.tsx @@ -3,10 +3,5 @@ import classes from "./ListTitle.module.scss"; export const LIST_TITLE_COLUMN_PROPS = { accessor: "title", id: "title", - Header: ( -
- Title - Name -
- ), + Header:
Profile Name
, }; diff --git a/src/components/layout/LoadingState/LoadingState.tsx b/src/components/layout/LoadingState/LoadingState.tsx index f4780e634..f0dbc3e89 100644 --- a/src/components/layout/LoadingState/LoadingState.tsx +++ b/src/components/layout/LoadingState/LoadingState.tsx @@ -17,11 +17,11 @@ const LoadingState: FC = ({ centerOnScreen, inline }) => { ); if (inline) { - return
{spinningElement}
; + return {spinningElement}; } return ( -
+
{spinningElement}
diff --git a/src/components/layout/PageContent/types.d.ts b/src/components/layout/PageContent/types.ts similarity index 100% rename from src/components/layout/PageContent/types.d.ts rename to src/components/layout/PageContent/types.ts diff --git a/src/components/layout/PageMain.module.scss b/src/components/layout/PageMain.module.scss index d6da64625..eb0da96ee 100644 --- a/src/components/layout/PageMain.module.scss +++ b/src/components/layout/PageMain.module.scss @@ -2,3 +2,10 @@ display: flex; flex-direction: column; } + +.pageContent { + display: flex; + flex-direction: column; + min-width: 0; + width: 100%; +} diff --git a/src/components/layout/PageMain.tsx b/src/components/layout/PageMain.tsx index 50f79dfcf..aff7220ba 100644 --- a/src/components/layout/PageMain.tsx +++ b/src/components/layout/PageMain.tsx @@ -8,7 +8,9 @@ interface PageMainProps { const PageMain: FC = ({ children }) => { return ( -
{children}
+
+
{children}
+
); }; diff --git a/src/components/layout/Redirecting/Redirecting.test.tsx b/src/components/layout/Redirecting/Redirecting.test.tsx index 7e0b1fe54..da0db8a01 100644 --- a/src/components/layout/Redirecting/Redirecting.test.tsx +++ b/src/components/layout/Redirecting/Redirecting.test.tsx @@ -6,12 +6,7 @@ describe("Redirecting", () => { render(); expect(screen.getByRole("status")).toBeInTheDocument(); - - const labelSpans = screen.getAllByText("Redirecting..."); - - expect(labelSpans).toHaveLength(2); - - expect(labelSpans[0]).toBeOffScreen(); - expect(labelSpans[1]).not.toBeOffScreen(); + expect(screen.getByText("Loading...")).toBeOffScreen(); + expect(screen.getByText("Redirecting...")).not.toBeOffScreen(); }); }); diff --git a/src/components/layout/Redirecting/Redirecting.tsx b/src/components/layout/Redirecting/Redirecting.tsx index 2259ec19a..dfb7d962c 100644 --- a/src/components/layout/Redirecting/Redirecting.tsx +++ b/src/components/layout/Redirecting/Redirecting.tsx @@ -1,12 +1,12 @@ +import LoadingState from "../LoadingState"; import type { FC } from "react"; import classes from "./Redirecting.module.scss"; const Redirecting: FC = () => { return (
- - Redirecting... - + + Redirecting...
diff --git a/src/components/layout/SearchHelpPopup.module.scss b/src/components/layout/SearchHelpPopup.module.scss index a48f95325..7e9181a43 100644 --- a/src/components/layout/SearchHelpPopup.module.scss +++ b/src/components/layout/SearchHelpPopup.module.scss @@ -2,7 +2,7 @@ width: 30%; } -:global(.p-modal) { +.modal { table { width: 60rem; } diff --git a/src/components/layout/SearchHelpPopup.tsx b/src/components/layout/SearchHelpPopup.tsx index b35922e41..785d4d45f 100644 --- a/src/components/layout/SearchHelpPopup.tsx +++ b/src/components/layout/SearchHelpPopup.tsx @@ -32,7 +32,7 @@ const SearchHelpPopup: FC = ({ open, onClose, data }) => { return ( open && ( - +

Available search terms for use in the search box. If multiple search terms are separated by OR, any of the conditions will match. diff --git a/src/components/layout/SidePanel/SidePanel.module.scss b/src/components/layout/SidePanel/SidePanel.module.scss index 1ccd6e99a..8979a5283 100644 --- a/src/components/layout/SidePanel/SidePanel.module.scss +++ b/src/components/layout/SidePanel/SidePanel.module.scss @@ -10,6 +10,7 @@ flex-direction: column; overflow: visible; padding-bottom: 0; + z-index: var(--z-sidepanel); } .medium { diff --git a/src/components/ui/ResponsiveButtons/types.d.ts b/src/components/ui/ResponsiveButtons/types.ts similarity index 100% rename from src/components/ui/ResponsiveButtons/types.d.ts rename to src/components/ui/ResponsiveButtons/types.ts diff --git a/src/constants.ts b/src/constants.ts index aacc3a981..dd66f4cf2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,7 @@ export const IS_DEV_ENV = import.meta.env.DEV; export const IS_SELF_HOSTED_ENV = import.meta.env.VITE_SELF_HOSTED_ENV; export const API_URL = import.meta.env.VITE_API_URL; export const API_URL_OLD = import.meta.env.VITE_API_URL_OLD; +export const API_URL_DEB_ARCHIVE = import.meta.env.VITE_API_URL_DEB_ARCHIVE; export const ROOT_PATH = import.meta.env.VITE_ROOT_PATH; export const API_VERSION = "2011-08-01"; export const APP_TITLE = import.meta.env.VITE_APP_TITLE; diff --git a/src/context/SidePanelProvider.module.scss b/src/context/SidePanelProvider.module.scss index bd6960472..193b8583d 100644 --- a/src/context/SidePanelProvider.module.scss +++ b/src/context/SidePanelProvider.module.scss @@ -14,6 +14,10 @@ column-gap: $sph--large; } +.title { + overflow-wrap: anywhere; +} + .outerDiv { display: flex; flex-grow: 1; diff --git a/src/context/auth.test.tsx b/src/context/auth.test.tsx new file mode 100644 index 000000000..0a80c524f --- /dev/null +++ b/src/context/auth.test.tsx @@ -0,0 +1,256 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useContext, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import useFeatures from "@/hooks/useFeatures"; +import { + getSameOriginPath, + getSameOriginUrl, + redirectToExternalUrl, + useGetAuthState, +} from "@/features/auth"; +import { authUser } from "@/tests/mocks/auth"; +import { HOMEPAGE_PATH } from "@/constants"; +import { ROUTES } from "@/libs/routes"; +import AuthProvider, { AuthContext } from "./auth"; + +const navigate = vi.hoisted(() => vi.fn()); +const mockUseLocation = vi.hoisted(() => vi.fn()); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + + return { + ...actual, + useNavigate: () => navigate, + useLocation: () => mockUseLocation(), + }; +}); + +vi.mock("@/hooks/useFeatures"); +vi.mock("@/features/auth"); + +describe("AuthProvider", () => { + const wrapperWithProvider = (queryClient: QueryClient) => { + return function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); + }; + }; + + const renderAuthContext = (queryClient: QueryClient) => { + return renderHook(() => useContext(AuthContext), { + wrapper: wrapperWithProvider(queryClient), + }); + }; + + beforeEach(() => { + navigate.mockReset(); + + mockUseLocation.mockReturnValue({ pathname: "/dashboard" }); + + vi.mocked(useGetAuthState).mockReturnValue({ + user: authUser, + isLoading: false, + isFetched: true, + }); + + vi.mocked(useFeatures).mockReturnValue({ + isFeatureEnabled: vi.fn(() => true), + isFeaturesLoading: false, + }); + + vi.mocked(getSameOriginUrl).mockReturnValue( + new URL("/dashboard", window.location.origin), + ); + vi.mocked(getSameOriginPath).mockReturnValue("/dashboard"); + vi.mocked(redirectToExternalUrl).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("disables auth-state query when handling auth callbacks", () => { + mockUseLocation.mockReturnValue({ pathname: "/auth/handle-auth" }); + + const queryClient = new QueryClient(); + renderAuthContext(queryClient); + + expect(useGetAuthState).toHaveBeenCalledWith({ enabled: false }); + }); + + it("exposes computed auth flags and feature checks", () => { + const isFeatureEnabled = vi.fn(() => true); + vi.mocked(useFeatures).mockReturnValue({ + isFeatureEnabled, + isFeaturesLoading: false, + }); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + expect(result.current.authorized).toBe(true); + expect(result.current.hasAccounts).toBe(true); + expect(result.current.authLoading).toBe(false); + expect(result.current.isFeatureEnabled("spa-dashboard")).toBe(true); + }); + + it("sets auth user and clears non-auth queries on logout", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["authUser"], authUser); + queryClient.setQueryData(["features", authUser.email], [{ key: "x" }]); + + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.setUser(authUser); + }); + + expect(queryClient.getQueryData(["authUser"])).toEqual(authUser); + + act(() => { + result.current.logout(); + }); + + expect(queryClient.getQueryData(["authUser"])).toBeNull(); + expect( + queryClient.getQueryData(["features", authUser.email]), + ).toBeUndefined(); + expect(navigate).toHaveBeenCalledWith(ROUTES.auth.login(), { + replace: true, + }); + }); + + it("navigates to homepage for unsafe redirect targets", () => { + vi.mocked(getSameOriginUrl).mockReturnValue(null); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect("https://google.com"); + }); + + expect(navigate).toHaveBeenCalledWith(HOMEPAGE_PATH, { replace: true }); + }); + + it("navigates internally with same-origin safe path", () => { + vi.mocked(getSameOriginUrl).mockReturnValue( + new URL("/dashboard/settings", window.location.origin), + ); + vi.mocked(getSameOriginPath).mockReturnValue("/dashboard/settings"); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect("/dashboard/settings", { replace: false }); + }); + + expect(navigate).toHaveBeenCalledWith("/dashboard/settings", { + replace: false, + }); + }); + + it("uses external redirect helper when external option is requested", () => { + const safeUrl = new URL("/dashboard/settings", window.location.origin); + vi.mocked(getSameOriginUrl).mockReturnValue(safeUrl); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect("/dashboard/settings", { + external: true, + replace: false, + }); + }); + + expect(redirectToExternalUrl).toHaveBeenCalledWith(safeUrl.toString(), { + replace: false, + }); + }); + + it("redirects to safe path fallback when same-origin URL has no path", () => { + vi.mocked(getSameOriginUrl).mockReturnValue( + new URL(window.location.origin), + ); + vi.mocked(getSameOriginPath).mockReturnValue(null); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect(window.location.origin, { replace: false }); + }); + + expect(navigate).toHaveBeenCalledWith(HOMEPAGE_PATH, { replace: false }); + }); + + it("exposes hasAccounts false when user has no accounts", () => { + vi.mocked(useGetAuthState).mockReturnValue({ + user: { ...authUser, accounts: [], current_account: "" }, + isLoading: false, + isFetched: true, + }); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + expect(result.current.authorized).toBe(true); + expect(result.current.hasAccounts).toBe(false); + }); + + it("renders redirecting state while external redirect is in progress", async () => { + const user = userEvent.setup(); + + const TriggerExternalRedirect = () => { + const { safeRedirect } = useContext(AuthContext); + + return ( + + ); + }; + + const queryClient = new QueryClient(); + + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Trigger redirect" })); + + expect(screen.getAllByText("Redirecting...").length).toBeGreaterThan(0); + }); + + it("provides initial auth context defaults", () => { + const { result } = renderHook(() => useContext(AuthContext)); + + expect(result.current.authLoading).toBe(false); + expect(result.current.authorized).toBe(false); + expect(result.current.hasAccounts).toBe(false); + expect(result.current.user).toBeNull(); + expect(result.current.isFeatureEnabled("spa-dashboard")).toBe(false); + expect(result.current.logout()).toBeUndefined(); + expect(result.current.setUser(authUser)).toBeUndefined(); + expect(result.current.safeRedirect()).toBeUndefined(); + expect(result.current.redirectToExternalUrl("/dashboard")).toBeUndefined(); + }); +}); diff --git a/src/context/profiles.tsx b/src/context/profiles.tsx new file mode 100644 index 000000000..b3f459c3a --- /dev/null +++ b/src/context/profiles.tsx @@ -0,0 +1,91 @@ +import type { UseMutateAsyncFunction } from "@tanstack/react-query"; +import type { FC, ReactNode } from "react"; +import { createContext, useCallback, useRef, useState } from "react"; + +export interface RemoveProfileParams { + id: number; + name: string; +} + +type RemoveProfileFn = UseMutateAsyncFunction< + unknown, + unknown, + RemoveProfileParams +>; + +const noopRemoveProfile: RemoveProfileFn = async () => { + throw new Error("removeProfile is not configured"); +}; + +interface ProfilesContextProps { + isProfileLimitReached: boolean; + setIsProfileLimitReached: (limitReached: boolean) => void; + profileLimit: number; + setProfileLimit: (limit: number) => void; + removeProfile: RemoveProfileFn; + setRemoveProfile: (removalFn: RemoveProfileFn) => void; + isRemovingProfile: boolean; + setIsRemovingProfile: (isRemoving: boolean) => void; +} + +const initialState = { + isProfileLimitReached: false, + setIsProfileLimitReached: () => undefined, + profileLimit: 0, + setProfileLimit: () => undefined, + removeProfile: noopRemoveProfile, + setRemoveProfile: () => undefined, + isRemovingProfile: false, + setIsRemovingProfile: () => undefined, +}; + +export const ProfilesContext = + createContext(initialState); + +interface ProfilesProviderProps { + readonly children: ReactNode; + readonly path: string; +} + +export const ProfilesProvider: FC = ({ + children, + path, +}) => { + const [isProfileLimitReached, setIsProfileLimitReached] = useState(false); + const [profileLimit, setProfileLimit] = useState(0); + const [resetKey, setResetKey] = useState(path); + const removeProfileRef = useRef(noopRemoveProfile); + const [isRemovingProfile, setIsRemovingProfile] = useState(false); + + const removeProfile: RemoveProfileFn = useCallback( + (variables, options) => removeProfileRef.current(variables, options), + [], + ); + + const setRemoveProfile: ProfilesContextProps["setRemoveProfile"] = + useCallback((removalFn) => { + removeProfileRef.current = removalFn; + }, []); + + if (path !== resetKey) { + setIsProfileLimitReached(false); + setResetKey(path); + } + + return ( + + {children} + + ); +}; diff --git a/src/context/sidePanel.tsx b/src/context/sidePanel.tsx index 3d56fe38d..835dba4ef 100644 --- a/src/context/sidePanel.tsx +++ b/src/context/sidePanel.tsx @@ -5,7 +5,7 @@ import usePageParams from "@/hooks/usePageParams"; import { Button, Icon, ICONS } from "@canonical/react-components"; import classNames from "classnames"; import type { FC, ReactNode } from "react"; -import { createContext, useEffect, useState } from "react"; +import { createContext, useEffect, useRef, useState } from "react"; import { useLocation } from "react-router"; import classes from "./SidePanelProvider.module.scss"; @@ -22,6 +22,7 @@ interface SidePanelContextProps { titleLabel?: string, ) => void; setSidePanelTitle: (title: ReactNode) => void; + setOnCloseOverride: (handler: (() => void) | undefined) => void; } const initialState: SidePanelContextProps = { @@ -30,6 +31,7 @@ const initialState: SidePanelContextProps = { closeSidePanel: () => undefined, setSidePanelContent: () => undefined, setSidePanelTitle: () => undefined, + setOnCloseOverride: () => undefined, }; export const SidePanelContext = @@ -43,6 +45,7 @@ const SidePanelProvider: FC = ({ children }) => { const [open, setOpen] = useState(false); const [size, setSize] = useState("small"); const [title, setTitle] = useState(undefined); + const onCloseOverrideRef = useRef<(() => void) | undefined>(undefined); const [titleLabel, setTitleLabel] = useState(""); const [body, setBody] = useState(null); @@ -57,6 +60,7 @@ const SidePanelProvider: FC = ({ children }) => { setBody(null); setSize("small"); sidePanel.setOpen(false); + onCloseOverrideRef.current = undefined; }; useEffect(() => { @@ -97,6 +101,9 @@ const SidePanelProvider: FC = ({ children }) => { closeSidePanel: handleSidePanelClose, setSidePanelContent: handleContentChange, setSidePanelTitle: handleTitleChange, + setOnCloseOverride: (handler) => { + onCloseOverrideRef.current = handler; + }, }} > {children} @@ -111,14 +118,18 @@ const SidePanelProvider: FC = ({ children }) => { {open && ( <>

-

{title}

+

+ {title} +

{titleLabel}

+ ); +}; + +export default AddLocalRepositoryButton; diff --git a/src/features/local-repositories/components/AddLocalRepositoryButton/index.ts b/src/features/local-repositories/components/AddLocalRepositoryButton/index.ts new file mode 100644 index 000000000..04ba30ba0 --- /dev/null +++ b/src/features/local-repositories/components/AddLocalRepositoryButton/index.ts @@ -0,0 +1 @@ +export { default } from "./AddLocalRepositoryButton"; diff --git a/src/features/local-repositories/components/AddLocalRepositorySidePanel/AddLocalRepositorySidePanel.tsx b/src/features/local-repositories/components/AddLocalRepositorySidePanel/AddLocalRepositorySidePanel.tsx new file mode 100644 index 000000000..370411667 --- /dev/null +++ b/src/features/local-repositories/components/AddLocalRepositorySidePanel/AddLocalRepositorySidePanel.tsx @@ -0,0 +1,111 @@ +import type { FC } from "react"; +import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; +import SidePanel from "@/components/layout/SidePanel"; +import useDebug from "@/hooks/useDebug"; +import useNotify from "@/hooks/useNotify"; +import usePageParams from "@/hooks/usePageParams"; +import { getFormikError } from "@/utils/formikErrors"; +import { Form, Input } from "@canonical/react-components"; +import { useFormik } from "formik"; +import { + type AddLocalRepositoryFormValues, + INITIAL_VALUES, + VALIDATION_SCHEMA, +} from "./constants"; +import { useCreateLocalRepository } from "../../api/useCreateLocalRepository"; +import Blocks from "@/components/layout/Blocks"; + +const AddLocalRepositorySidePanel: FC = () => { + const debug = useDebug(); + const { notify } = useNotify(); + const { createPageParamsSetter } = usePageParams(); + const { createRepository, isCreatingRepository } = useCreateLocalRepository(); + + const closeSidePanel = createPageParamsSetter({ + sidePath: [], + name: "", + }); + + const handleSubmit = async (values: AddLocalRepositoryFormValues) => { + const valuesforCreation = { + display_name: values.name, + comment: values.description, + distribution: values.distribution, + component: values.component, + }; + + try { + await createRepository(valuesforCreation); + + closeSidePanel(); + + notify.success({ + title: `You have successfully added ${values.name}`, + message: + "The local repository has been created and is now available to import packages.", + }); + } catch (error) { + debug(error); + } + }; + + const formik = useFormik({ + initialValues: INITIAL_VALUES, + onSubmit: handleSubmit, + validationSchema: VALIDATION_SCHEMA, + validateOnMount: true, + }); + + return ( + <> + Add local repository + +
+ + + + + + + + + + + + + + +
+ + ); +}; + +export default AddLocalRepositorySidePanel; diff --git a/src/features/local-repositories/components/AddLocalRepositorySidePanel/constants.ts b/src/features/local-repositories/components/AddLocalRepositorySidePanel/constants.ts new file mode 100644 index 000000000..b5439a8d3 --- /dev/null +++ b/src/features/local-repositories/components/AddLocalRepositorySidePanel/constants.ts @@ -0,0 +1,22 @@ +import * as Yup from "yup"; + +export interface AddLocalRepositoryFormValues { + name: string; + description: string; + distribution: string; + component: string; +} + +export const VALIDATION_SCHEMA = Yup.object().shape({ + name: Yup.string().required("This field is required."), + description: Yup.string(), + distribution: Yup.string().required("This field is required."), + component: Yup.string().required("This field is required."), +}); + +export const INITIAL_VALUES = { + name: "", + description: "", + distribution: "", + component: "", +}; diff --git a/src/features/local-repositories/components/AddLocalRepositorySidePanel/index.ts b/src/features/local-repositories/components/AddLocalRepositorySidePanel/index.ts new file mode 100644 index 000000000..4c97a89ae --- /dev/null +++ b/src/features/local-repositories/components/AddLocalRepositorySidePanel/index.ts @@ -0,0 +1 @@ +export { default } from "./AddLocalRepositorySidePanel"; diff --git a/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/EditLocalRepositoryForm.tsx b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/EditLocalRepositoryForm.tsx new file mode 100644 index 000000000..09f7e89e5 --- /dev/null +++ b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/EditLocalRepositoryForm.tsx @@ -0,0 +1,111 @@ +import type { FC } from "react"; +import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; +import useDebug from "@/hooks/useDebug"; +import useNotify from "@/hooks/useNotify"; +import usePageParams from "@/hooks/usePageParams"; +import { getFormikError } from "@/utils/formikErrors"; +import { Form, Input } from "@canonical/react-components"; +import { useFormik } from "formik"; +import { + type EditLocalRepositoryFormValues, + VALIDATION_SCHEMA, +} from "./constants"; +import Blocks from "@/components/layout/Blocks"; +import { useUpdateLocalRepository } from "../../../api/useUpdateLocalRepository"; +import type { Local } from "../../../types"; +import ReadOnlyField from "@/components/form/ReadOnlyField"; + +interface EditLocalRepositoryFormProps { + readonly repository: Local; +} + +const EditLocalRepositoryForm: FC = ({ + repository, +}) => { + const debug = useDebug(); + const { notify } = useNotify(); + const { sidePath, popSidePath, createPageParamsSetter } = usePageParams(); + const { updateRepository, isUpdatingRepository } = useUpdateLocalRepository(); + + const closeSidePanel = createPageParamsSetter({ + sidePath: [], + name: "", + }); + + const handleSubmit = async (values: EditLocalRepositoryFormValues) => { + const localToUpdate = { + name: repository.name, + display_name: values.displayName ?? repository.display_name, + comment: values.description ?? repository.comment, + }; + + try { + await updateRepository({ local: localToUpdate }); + + closeSidePanel(); + + notify.success({ + title: `You have successfully edited ${values.displayName}`, + message: "The local repository details have been updated.", + }); + } catch (error) { + debug(error); + } + }; + + const formik = useFormik({ + initialValues: { + displayName: repository.display_name, + description: repository.comment, + }, + onSubmit: handleSubmit, + validationSchema: VALIDATION_SCHEMA, + validateOnMount: true, + }); + + return ( +
+ + + + + + + + + + + + + 1} + onBackButtonPress={popSidePath} + onCancel={closeSidePanel} + /> + + ); +}; + +export default EditLocalRepositoryForm; diff --git a/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/constants.ts b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/constants.ts new file mode 100644 index 000000000..1134c5bfb --- /dev/null +++ b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/constants.ts @@ -0,0 +1,11 @@ +import * as Yup from "yup"; + +export interface EditLocalRepositoryFormValues { + displayName?: string; + description?: string; +} + +export const VALIDATION_SCHEMA = Yup.object().shape({ + displayName: Yup.string(), + description: Yup.string(), +}); diff --git a/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/index.ts b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/index.ts new file mode 100644 index 000000000..979d64886 --- /dev/null +++ b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositoryForm/index.ts @@ -0,0 +1 @@ +export { default } from "./EditLocalRepositoryForm"; diff --git a/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositorySidePanel.tsx b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositorySidePanel.tsx new file mode 100644 index 000000000..6128dd151 --- /dev/null +++ b/src/features/local-repositories/components/EditLocalRepositorySidePanel/EditLocalRepositorySidePanel.tsx @@ -0,0 +1,23 @@ +import type { FC } from "react"; +import SidePanel from "@/components/layout/SidePanel"; +import { useGetPageLocalRepository } from "../../api/useGetPageLocalRepository"; +import EditLocalRepositoryForm from "./EditLocalRepositoryForm"; + +const EditLocalRepositorySidePanel: FC = () => { + const { repository, isGettingRepository } = useGetPageLocalRepository(); + + if (isGettingRepository) { + return ; + } + + return ( + <> + Edit {repository.display_name} + + + + + ); +}; + +export default EditLocalRepositorySidePanel; diff --git a/src/features/local-repositories/components/EditLocalRepositorySidePanel/index.ts b/src/features/local-repositories/components/EditLocalRepositorySidePanel/index.ts new file mode 100644 index 000000000..54e954f85 --- /dev/null +++ b/src/features/local-repositories/components/EditLocalRepositorySidePanel/index.ts @@ -0,0 +1 @@ +export { default } from "./EditLocalRepositorySidePanel"; diff --git a/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/ImportRepositoryPackagesSidePanel.module.scss b/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/ImportRepositoryPackagesSidePanel.module.scss new file mode 100644 index 000000000..2a4229b82 --- /dev/null +++ b/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/ImportRepositoryPackagesSidePanel.module.scss @@ -0,0 +1,16 @@ +@import "vanilla-framework/scss/settings_spacing"; +@import "vanilla-framework/scss/settings_colors"; + +.row { + display: flex; + flex-direction: row; +} + +.button { + margin: 2.5rem 0 auto $sph--large; + width: 13.5rem; +} + +.file { + padding: $spv--small 0 $spv--large; +} diff --git a/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/ImportRepositoryPackagesSidePanel.tsx b/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/ImportRepositoryPackagesSidePanel.tsx new file mode 100644 index 000000000..baeec73e7 --- /dev/null +++ b/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/ImportRepositoryPackagesSidePanel.tsx @@ -0,0 +1,189 @@ +import { useState, type FC } from "react"; +import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; +import SidePanel from "@/components/layout/SidePanel"; +import useDebug from "@/hooks/useDebug"; +import useNotify from "@/hooks/useNotify"; +import usePageParams from "@/hooks/usePageParams"; +import { getFormikError } from "@/utils/formikErrors"; +import { + Button, + Form, + Input, + List, + Notification, +} from "@canonical/react-components"; +import { useFormik } from "formik"; +import Blocks from "@/components/layout/Blocks"; +import { useGetPageLocalRepository } from "../../api/useGetPageLocalRepository"; +import * as Yup from "yup"; +import { useImportRepositoryPackages } from "../../api/useImportRepositoryPackages"; +import LoadingState from "@/components/layout/LoadingState"; +import classes from "./ImportRepositoryPackagesSidePanel.module.scss"; +import { pluralizeWithCount } from "@/utils/_helpers"; +import type { TaskStatus } from "../../types/Task"; + +const ImportRepositoryPackagesSidePanel: FC = () => { + const debug = useDebug(); + const { notify } = useNotify(); + const { sidePath, popSidePath, name, createPageParamsSetter } = + usePageParams(); + const { repository, isGettingRepository } = useGetPageLocalRepository(); + + const { importRepositoryPackages, isImportingRepositoryPackages } = + useImportRepositoryPackages(); + const closeSidePanel = createPageParamsSetter({ + sidePath: [], + name: "", + }); + + const repositoryName = `locals/${name}`; + const [validateTask, setValidateTask] = useState< + | { + status: TaskStatus; + output: string[]; + } + | undefined + >(undefined); + + const handleSubmit = async (values: { source: string }) => { + try { + await importRepositoryPackages({ + name: repositoryName, + url: values.source, + }); + + closeSidePanel(); + + notify.success({ + title: `You have marked ${repository?.display_name} to import packages`, + message: + "An activity has been queued to import the packages to the local repository.", + }); + } catch (error) { + debug(error); + } + }; + + const formik = useFormik({ + initialValues: { source: "" }, + onSubmit: handleSubmit, + validationSchema: Yup.object().shape({ + source: Yup.string().required("This field is required."), + }), + }); + + if (isGettingRepository) { + return ; + } + + const handleValidate = async () => { + try { + setValidateTask(undefined); + + const { data } = await importRepositoryPackages({ + name: repositoryName, + url: formik.values.source, + validate_only: true, + }); + + const output = data.output ? data.output.split(", ") : []; + setValidateTask({ status: data.status, output }); + } catch (error) { + debug(error); + } + }; + + const hasPackages = + validateTask?.status === "succeeded" && !!validateTask.output.length; + + const packagesCount = hasPackages + ? pluralizeWithCount(validateTask?.output.length, "package") + : "packages"; + + return ( + <> + + Import packages to {repository.display_name} + + +
+ + +
+ + + +
+ + {validateTask?.status === "failed" && ( + + + You can still proceed to import packages, although this + process may fail if we can't fetch the packages from + the source provided. + + + )} + + {validateTask?.status === "succeeded" && ( + <> + {!validateTask?.output.length ? ( + + ) : ( + ( +
+ {file} +
+ ))} + divided + /> + )} + + )} +
+
+ + 1} + onBackButtonPress={popSidePath} + onCancel={closeSidePanel} + /> + +
+ + ); +}; + +export default ImportRepositoryPackagesSidePanel; diff --git a/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/index.ts b/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/index.ts new file mode 100644 index 000000000..25824a91d --- /dev/null +++ b/src/features/local-repositories/components/ImportRepositoryPackagesSidePanel/index.ts @@ -0,0 +1 @@ +export { default } from "./ImportRepositoryPackagesSidePanel"; diff --git a/src/features/local-repositories/components/LocalRepositoriesContainer/LocalRepositoriesContainer.tsx b/src/features/local-repositories/components/LocalRepositoriesContainer/LocalRepositoriesContainer.tsx new file mode 100644 index 000000000..1b24fb262 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesContainer/LocalRepositoriesContainer.tsx @@ -0,0 +1,58 @@ +import HeaderWithSearch from "@/components/form/HeaderWithSearch"; +import LoadingState from "@/components/layout/LoadingState"; +import type { FC } from "react"; +import LocalRepositoriesList from "../LocalRepositoriesList"; +import AddLocalRepositoryButton from "../AddLocalRepositoryButton"; +import EmptyState from "@/components/layout/EmptyState"; +import type { Local } from "../../types"; +import usePageParams from "@/hooks/usePageParams"; + +interface LocalRepositoriesContainerProps { + readonly isPending: boolean; + readonly repositories: Local[]; +} + +const LocalRepositoriesContainer: FC = ({ + isPending, + repositories, +}) => { + const { search } = usePageParams(); + + if (isPending) { + return ; + } + + if (!search && !repositories.length) { + return ( + +

+ Use local repositories to host internal packages and distribute + them to your fleet, either through publications or via repository + profiles. +

+
+ Learn more about repository mirroring + + + } + cta={[]} + /> + ); + } + + return ( + <> + + + + ); +}; + +export default LocalRepositoriesContainer; diff --git a/src/features/local-repositories/components/LocalRepositoriesContainer/index.ts b/src/features/local-repositories/components/LocalRepositoriesContainer/index.ts new file mode 100644 index 000000000..b0aa7ffe5 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesContainer/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoriesContainer"; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/LocalRepositoriesList.tsx b/src/features/local-repositories/components/LocalRepositoriesList/LocalRepositoriesList.tsx new file mode 100644 index 000000000..d77dad733 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/LocalRepositoriesList.tsx @@ -0,0 +1,113 @@ +import { LIST_ACTIONS_COLUMN_PROPS } from "@/components/layout/ListActions"; +import NoData from "@/components/layout/NoData"; +import ResponsiveTable from "@/components/layout/ResponsiveTable"; +import { Button } from "@canonical/react-components"; +import { useMemo, type FC } from "react"; +import type { Column, CellProps } from "react-table"; +import type { Local } from "@canonical/landscape-openapi"; +import usePageParams from "@/hooks/usePageParams"; +import { TablePagination } from "@/components/layout/TablePagination"; +import LocalRepositoriesListActions from "./components/LocalRepositoriesListActions"; +import LocalRepositoryPackagesCount from "./components/LocalRepositoryPackagesCount"; +import LocalRepositoryPublicationsCount from "./components/LocalRepositoryPublicationsCount"; + +interface LocalRepositoriesListProps { + readonly repositories: Local[]; +} + +const LocalRepositoriesList: FC = ({ + repositories, +}) => { + const { search, currentPage, pageSize, createPageParamsSetter } = + usePageParams(); + + const pagedRepositories = useMemo( + () => + repositories.slice((currentPage - 1) * pageSize, currentPage * pageSize), + [repositories, currentPage, pageSize], + ); + + const columns = useMemo[]>( + () => [ + { + accessor: "name", + Header: "Name", + meta: { + ariaLabel: ({ original: repository }) => + `${repository.display_name} local repository name`, + }, + Cell: ({ row: { original: repository } }: CellProps) => ( + + ), + }, + { + Header: "Description", + meta: { + ariaLabel: ({ original: repository }) => + repository.comment + ? `${repository.display_name} profile description` + : `No description for ${repository.display_name} profile`, + }, + Cell: ({ row: { original: repository } }: CellProps) => + repository.comment || , + }, + { + Header: "Packages", + meta: { + ariaLabel: ({ original: repository }) => + `${repository.display_name} local repository packages`, + }, + Cell: ({ row: { original: repository } }: CellProps) => ( + + ), + }, + { + Header: "Publications", + meta: { + ariaLabel: ({ original: repository }) => + `${repository.display_name} local repository publications`, + }, + Cell: ({ row: { original: repository } }: CellProps) => ( + + ), + }, + { + ...LIST_ACTIONS_COLUMN_PROPS, + meta: { + ariaLabel: ({ original: repository }) => + `"${repository.display_name}" local repository actions`, + }, + Cell: ({ row: { original: repository } }: CellProps) => ( + + ), + }, + ], + [createPageParamsSetter], + ); + + return ( + <> + + + + ); +}; + +export default LocalRepositoriesList; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoriesListActions/LocalRepositoriesListActions.tsx b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoriesListActions/LocalRepositoriesListActions.tsx new file mode 100644 index 000000000..9406e380b --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoriesListActions/LocalRepositoriesListActions.tsx @@ -0,0 +1,59 @@ +import type { FC } from "react"; +import type { Local } from "../../../../types"; +import ListActions from "@/components/layout/ListActions"; +import { useBoolean } from "usehooks-ts"; +import RemoveLocalRepositoryModal from "../../../RemoveLocalRepositoryModal"; +import { useGetRepositoryActions } from "../../../../hooks"; +import PublishLocalRepositoryGuard from "../../../PublishLocalRepositoryGuard"; + +interface LocalRepositoriesListActionsProps { + readonly repository: Local; +} + +const LocalRepositoriesListActions: FC = ({ + repository, +}) => { + const { + value: isRemovalModalOpen, + setTrue: openRemovalModal, + setFalse: closeRemovalModal, + } = useBoolean(); + + const { + value: isPublishGuardOpen, + setTrue: openPublishGuard, + setFalse: closePublishGuard, + } = useBoolean(); + + const { viewAction, actions, destructiveActions } = useGetRepositoryActions({ + repository, + openRemovalModal, + openPublishGuard, + }); + + return ( + <> + + + {isRemovalModalOpen && ( + + )} + + {isPublishGuardOpen && ( + + )} + + ); +}; + +export default LocalRepositoriesListActions; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoriesListActions/index.ts b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoriesListActions/index.ts new file mode 100644 index 000000000..5f36d912e --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoriesListActions/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoriesListActions"; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPackagesCount/LocalRepositoryPackagesCount.tsx b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPackagesCount/LocalRepositoryPackagesCount.tsx new file mode 100644 index 000000000..a258639de --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPackagesCount/LocalRepositoryPackagesCount.tsx @@ -0,0 +1,25 @@ +import LoadingState from "@/components/layout/LoadingState"; +import type { FC } from "react"; +import type { Local } from "@canonical/landscape-openapi"; +import { useGetRepositoryPackages } from "../../../../api"; +import { pluralizeWithCount } from "@/utils/_helpers"; + +interface LocalRepositoryPackagesCountProps { + readonly repository: Local; +} + +const LocalRepositoryPackagesCount: FC = ({ + repository, +}) => { + const { packages, isGettingRepositoryPackages } = useGetRepositoryPackages( + repository.name, + ); + + if (isGettingRepositoryPackages) { + return ; + } + + return pluralizeWithCount(packages.length, "package"); +}; + +export default LocalRepositoryPackagesCount; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPackagesCount/index.ts b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPackagesCount/index.ts new file mode 100644 index 000000000..dd6665e4e --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPackagesCount/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoryPackagesCount"; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPublicationsCount/LocalRepositoryPublicationsCount.tsx b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPublicationsCount/LocalRepositoryPublicationsCount.tsx new file mode 100644 index 000000000..39c3baa2f --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPublicationsCount/LocalRepositoryPublicationsCount.tsx @@ -0,0 +1,39 @@ +import LoadingState from "@/components/layout/LoadingState"; +import type { FC } from "react"; +import type { Local } from "../../../../types"; +import useGetPublicationsBySource from "../../../../api/useGetPublicationsBySource"; +import { pluralizeWithCount } from "@/utils/_helpers"; +import StaticLink from "@/components/layout/StaticLink"; +import { ROUTES } from "@/libs/routes"; + +interface LocalRepositoryPublicationsCountProps { + readonly repository: Local; +} + +const LocalRepositoryPublicationsCount: FC< + LocalRepositoryPublicationsCountProps +> = ({ repository }) => { + const { publications, isGettingPublications } = useGetPublicationsBySource( + repository.name, + ); + + if (isGettingPublications) { + return ; + } + + if (!publications.length) { + return "0 publications"; + } + + return ( + + {pluralizeWithCount(publications.length, "publication")} + + ); +}; + +export default LocalRepositoryPublicationsCount; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPublicationsCount/index.ts b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPublicationsCount/index.ts new file mode 100644 index 000000000..c74a12608 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/components/LocalRepositoryPublicationsCount/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoryPublicationsCount"; diff --git a/src/features/local-repositories/components/LocalRepositoriesList/index.ts b/src/features/local-repositories/components/LocalRepositoriesList/index.ts new file mode 100644 index 000000000..be05544d3 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoriesList/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoriesList"; diff --git a/src/features/local-repositories/components/LocalRepositoryPublicationsList/LocalRepositoryPublicationsList.tsx b/src/features/local-repositories/components/LocalRepositoryPublicationsList/LocalRepositoryPublicationsList.tsx new file mode 100644 index 000000000..eee50be89 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoryPublicationsList/LocalRepositoryPublicationsList.tsx @@ -0,0 +1,81 @@ +import { ROUTES } from "@/libs/routes"; +import type { Publication } from "@/features/publications"; +import { useMemo, type FC } from "react"; +import StaticLink from "@/components/layout/StaticLink"; +import ResponsiveTable from "@/components/layout/ResponsiveTable"; +import type { Column, CellProps } from "react-table"; +import usePageParams from "@/hooks/usePageParams"; +import { TablePagination } from "@/components/layout/TablePagination"; + +interface LocalRepositoryPublicationsListProps { + readonly publications: Publication[]; + readonly openNewTab?: boolean; +} + +const LocalRepositoryPublicationsList: FC< + LocalRepositoryPublicationsListProps +> = ({ publications, openNewTab }) => { + const { currentPage, pageSize } = usePageParams(); + + const pagedPublications = useMemo( + () => + publications.slice((currentPage - 1) * pageSize, currentPage * pageSize), + [publications, currentPage, pageSize], + ); + + const columns = useMemo[]>(() => { + const newTabProps = openNewTab + ? { + target: "_blank", + rel: "noopener noreferrer", + } + : undefined; + + return [ + { + Header: "Publication", + meta: { + ariaLabel: ({ original: publication }) => + `${publication.displayName} publication name`, + }, + Cell: ({ row: { original: publication } }: CellProps) => ( + + {publication.label} + + ), + }, + { + Header: "Date published", + meta: { + ariaLabel: ({ original: publication }) => + `Date when the ${publication.displayName} publication was published`, + }, + Cell: ({ row: { original: publication } }: CellProps) => + publication.publishTime, + }, + ] as Column[]; + }, [openNewTab]); + + return ( + <> + + + + ); +}; + +export default LocalRepositoryPublicationsList; diff --git a/src/features/local-repositories/components/LocalRepositoryPublicationsList/index.ts b/src/features/local-repositories/components/LocalRepositoryPublicationsList/index.ts new file mode 100644 index 000000000..3b3852203 --- /dev/null +++ b/src/features/local-repositories/components/LocalRepositoryPublicationsList/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoryPublicationsList"; diff --git a/src/features/local-repositories/components/NoPublicationTargetsModal/NoPublicationTargetsModal.tsx b/src/features/local-repositories/components/NoPublicationTargetsModal/NoPublicationTargetsModal.tsx new file mode 100644 index 000000000..079bc0e73 --- /dev/null +++ b/src/features/local-repositories/components/NoPublicationTargetsModal/NoPublicationTargetsModal.tsx @@ -0,0 +1,35 @@ +import type { FC } from "react"; +import { ConfirmationModal } from "@canonical/react-components"; +import { useNavigate } from "react-router"; +import { ROUTES } from "@/libs/routes"; + +interface NoPublicationTargetsModalProps { + readonly close: () => void; +} + +const NoPublicationTargetsModal: FC = ({ + close, +}) => { + const navigate = useNavigate(); + + return ( + + navigate(ROUTES.repositories.publicationTargets({ sidePath: ["add"] })) + } + close={close} + renderInPortal + > +

+ In order to publish a mirror or a local repository you must first add a + publication target to indicate the location you wish to publish that + mirror to. +

+
+ ); +}; + +export default NoPublicationTargetsModal; diff --git a/src/features/local-repositories/components/NoPublicationTargetsModal/index.ts b/src/features/local-repositories/components/NoPublicationTargetsModal/index.ts new file mode 100644 index 000000000..43267d4b5 --- /dev/null +++ b/src/features/local-repositories/components/NoPublicationTargetsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./NoPublicationTargetsModal"; diff --git a/src/features/local-repositories/components/PublishLocalRepositoryGuard/PublishLocalRepositoryGuard.tsx b/src/features/local-repositories/components/PublishLocalRepositoryGuard/PublishLocalRepositoryGuard.tsx new file mode 100644 index 000000000..772c0117e --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositoryGuard/PublishLocalRepositoryGuard.tsx @@ -0,0 +1,34 @@ +import { type FC } from "react"; +import NoPublicationTargetsModal from "../NoPublicationTargetsModal"; +import { useGetPublicationTargets } from "@/features/publication-targets"; +import LoadingState from "@/components/layout/LoadingState"; +import usePageParams from "@/hooks/usePageParams"; +import type { Local } from "../../types"; + +interface PublishRepositoryGuardProps { + readonly close: () => void; + readonly repository: Local; +} + +const PublishLocalRepositoryGuard: FC = ({ + close, + repository, +}) => { + const { publicationTargets, isGettingPublicationTargets } = + useGetPublicationTargets(); + const { setPageParams } = usePageParams(); + + if (isGettingPublicationTargets) { + return ; + } + + if (!publicationTargets.length) { + return ; + } + + setPageParams({ sidePath: ["publish"], name: repository.local_id }); + + return null; +}; + +export default PublishLocalRepositoryGuard; diff --git a/src/features/local-repositories/components/PublishLocalRepositoryGuard/index.ts b/src/features/local-repositories/components/PublishLocalRepositoryGuard/index.ts new file mode 100644 index 000000000..5ddc8589a --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositoryGuard/index.ts @@ -0,0 +1 @@ +export { default } from "./PublishLocalRepositoryGuard"; diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/PublishLocalRepositorySidePanel.module.scss b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/PublishLocalRepositorySidePanel.module.scss new file mode 100644 index 000000000..4e34d3593 --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/PublishLocalRepositorySidePanel.module.scss @@ -0,0 +1,32 @@ +@import "vanilla-framework/scss/settings_spacing"; +@import "vanilla-framework/scss/settings_colors"; + +.settingLabel { + margin-right: $sph--x-small; +} + +.tooltipPositionElement { + display: inline !important; +} + +.distribution { + align-items: center; + display: flex; + justify-content: space-between; + padding: $spv--small $sph--small; + background-color: $colors--theme--background-inputs; +} + +.radio { + display: flex; + align-items: end; + margin-bottom: $spv--large; + + > label { + margin-right: $sph--x-large; + } + + & span { + padding-left: $sph--x-large; + } +} diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/PublishLocalRepositorySidePanel.tsx b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/PublishLocalRepositorySidePanel.tsx new file mode 100644 index 000000000..afbf5af57 --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/PublishLocalRepositorySidePanel.tsx @@ -0,0 +1,58 @@ +import { RadioInput } from "@canonical/react-components"; +import type { FC } from "react"; +import classes from "./PublishLocalRepositorySidePanel.module.scss"; +import useGetPublicationsBySource from "../../api/useGetPublicationsBySource"; +import SidePanel from "@/components/layout/SidePanel"; +import PublishRepositoryNewForm from "./components/PublishRepositoryNewForm"; +import PublishRepositoryExistingForm from "./components/PublishRepositoryExistingForm"; +import { useBoolean } from "usehooks-ts"; +import { useGetPageLocalRepository } from "../../api/useGetPageLocalRepository"; + +const PublishLocalRepositorySidePanel: FC = () => { + const { repository, isGettingRepository } = useGetPageLocalRepository(); + const { publications, isGettingPublications } = useGetPublicationsBySource( + repository?.name, + ); + + const { value: useNewPublication, toggle } = useBoolean(true); + + if (isGettingRepository || isGettingPublications) { + return ; + } + + return ( + <> + Publish {repository.display_name} + + {!!publications.length && ( + <> + +
+ + +
+ + )} + + {useNewPublication ? ( + + ) : ( + + )} +
+ + ); +}; + +export default PublishLocalRepositorySidePanel; diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryContentsBlock/PublishRepositoryContentsBlock.tsx b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryContentsBlock/PublishRepositoryContentsBlock.tsx new file mode 100644 index 000000000..d69cdbf59 --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryContentsBlock/PublishRepositoryContentsBlock.tsx @@ -0,0 +1,30 @@ +import Blocks from "@/components/layout/Blocks"; +import type { FC } from "react"; +import type { Local } from "../../../../types"; +import ReadOnlyField from "@/components/form/ReadOnlyField"; + +interface PublishRepositoryContentsBlockProps { + readonly repository: Local; +} + +const PublishRepositoryContentsBlock: FC< + PublishRepositoryContentsBlockProps +> = ({ repository }) => { + return ( + + + + + + ); +}; + +export default PublishRepositoryContentsBlock; diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryContentsBlock/index.ts b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryContentsBlock/index.ts new file mode 100644 index 000000000..f53a5747d --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryContentsBlock/index.ts @@ -0,0 +1 @@ +export { default } from "./PublishRepositoryContentsBlock"; diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryExistingForm/PublishRepositoryExistingForm.tsx b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryExistingForm/PublishRepositoryExistingForm.tsx new file mode 100644 index 000000000..b5812e3e6 --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryExistingForm/PublishRepositoryExistingForm.tsx @@ -0,0 +1,207 @@ +import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; +import Blocks from "@/components/layout/Blocks"; +import useDebug from "@/hooks/useDebug"; +import usePageParams from "@/hooks/usePageParams"; +import { getFormikError } from "@/utils/formikErrors"; +import { + Form, + Icon, + Input, + Select, + Tooltip, +} from "@canonical/react-components"; +import { useFormik } from "formik"; +import { useMemo, type FC } from "react"; +import { + SETTINGS_HELP_TEXT, + VALIDATION_SCHEMA_EXISTING, +} from "../../constants"; +import useNotify from "@/hooks/useNotify"; +import classes from "../../PublishLocalRepositorySidePanel.module.scss"; +import type { SelectOption } from "@/types/SelectOption"; +import type { Local } from "../../../../types"; +import { + type Publication, + usePublishPublication, +} from "@/features/publications"; +import ReadOnlyField from "@/components/form/ReadOnlyField"; +import PublishRepositoryContentsBlock from "../PublishRepositoryContentsBlock"; + +interface PublishRepositoryExistingFormProps { + readonly repository: Local; + readonly publications: Publication[]; +} + +const PublishRepositoryExistingForm: FC = ({ + repository, + publications, +}) => { + const debug = useDebug(); + const { notify } = useNotify(); + const { sidePath, popSidePath, createPageParamsSetter } = usePageParams(); + const { publishPublication, isPublishingPublication } = + usePublishPublication(); + + const closeSidePanel = createPageParamsSetter({ + sidePath: [], + name: "", + }); + + const handleSubmit = async (values: { name: string }) => { + try { + await publishPublication({ name: values.name }); + + closeSidePanel(); + + notify.success({ + title: `You have marked ${repository.display_name} to be published`, + message: + "An activity has been queued to publish the selected publication to the designated target.", + }); + } catch (error) { + debug(error); + } + }; + + const formik = useFormik({ + initialValues: { name: "" }, + onSubmit: handleSubmit, + validationSchema: VALIDATION_SCHEMA_EXISTING, + validateOnMount: true, + }); + + const publicationOptions = useMemo( + () => [ + { label: "Select publication", value: "" }, + ...publications.map((publication) => ({ + label: publication.displayName, + value: publication.name, + })), + ], + [publications], + ); + + const publication = publications.find( + ({ name }) => name === formik.values.name, + ); + + return ( +
+ + + + + Hash based indexing + + + + Help + + + } + checked={publication?.acquireByHash ?? false} + disabled + /> + + + + Automatic installation + + + + Help + + + } + checked={publication?.notAutomatic ?? false} + disabled + /> + + + Automatic upgrades + + + Help + + + } + checked={publication?.butAutomaticUpgrades ?? false} + disabled + /> + + + + + + + + 1} + onBackButtonPress={popSidePath} + /> + + ); +}; + +export default PublishRepositoryExistingForm; diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryExistingForm/index.ts b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryExistingForm/index.ts new file mode 100644 index 000000000..bfa5dd399 --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryExistingForm/index.ts @@ -0,0 +1 @@ +export { default } from "./PublishRepositoryExistingForm"; diff --git a/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryNewForm/PublishRepositoryNewForm.tsx b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryNewForm/PublishRepositoryNewForm.tsx new file mode 100644 index 000000000..c4e6434f2 --- /dev/null +++ b/src/features/local-repositories/components/PublishLocalRepositorySidePanel/components/PublishRepositoryNewForm/PublishRepositoryNewForm.tsx @@ -0,0 +1,236 @@ +import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; +import Blocks from "@/components/layout/Blocks"; +import useDebug from "@/hooks/useDebug"; +import usePageParams from "@/hooks/usePageParams"; +import { getFormikError } from "@/utils/formikErrors"; +import { + Form, + Icon, + Input, + Select, + Textarea, + Tooltip, +} from "@canonical/react-components"; +import { useFormik } from "formik"; +import { useMemo, type FC } from "react"; +import { + type PublishRepositoryNewFormValues, + SETTINGS_HELP_TEXT, + VALIDATION_SCHEMA_NEW, +} from "../../constants"; +import useNotify from "@/hooks/useNotify"; +import classes from "../../PublishLocalRepositorySidePanel.module.scss"; +import type { SelectOption } from "@/types/SelectOption"; +import { useGetPublicationTargets } from "@/features/publication-targets"; +import type { Local } from "../../../../types"; +import { + useAddPublication, + usePublishPublication, +} from "@/features/publications"; +import PublishRepositoryContentsBlock from "../PublishRepositoryContentsBlock"; + +interface PublishRepositoryNewFormProps { + readonly repository: Local; +} + +const PublishRepositoryNewForm: FC = ({ + repository, +}) => { + const debug = useDebug(); + const { notify } = useNotify(); + const { sidePath, popSidePath, createPageParamsSetter } = usePageParams(); + const { publicationTargets, isGettingPublicationTargets } = + useGetPublicationTargets(); + const { addPublication, isAddingPublication } = useAddPublication(); + const { publishPublication, isPublishingPublication } = + usePublishPublication(); + + const closeSidePanel = createPageParamsSetter({ + sidePath: [], + name: "", + }); + + const initialValues: PublishRepositoryNewFormValues = { + name: "", + publication_target: "", + signing_key: "", + hash_indexing: false, + automatic_installation: false, + automatic_upgrades: false, + skip_bz2: false, + skip_content_indexing: false, + }; + + const handleSubmit = async (values: PublishRepositoryNewFormValues) => { + const valuesforCreation = { + publication_target: values.publication_target, + source: repository.name, + distribution: repository.distribution, + hash_indexing: values.hash_indexing, + automatic_installation: values.automatic_installation, + automatic_upgrades: values.automatic_upgrades, + skip_bz2: values.skip_bz2, + skip_content_indexing: values.skip_content_indexing, + gpg_key: values.signing_key, + }; + + try { + const { data: publication } = await addPublication(valuesforCreation); + + await publishPublication({ name: publication.name }); + + closeSidePanel(); + + notify.success({ + title: `You have marked ${repository.display_name} to be published`, + message: + "A publication has been created and an activity has been queued to publish it to the designated target.", + }); + } catch (error) { + debug(error); + } + }; + + const formik = useFormik({ + initialValues: initialValues, + onSubmit: handleSubmit, + validationSchema: VALIDATION_SCHEMA_NEW, + validateOnMount: true, + }); + + const publicationTargetOptions = useMemo( + () => [ + { label: "Select publication target", value: "" }, + ...publicationTargets.map((publicationTarget) => ({ + label: publicationTarget.displayName, + value: publicationTarget.name, + })), + ], + [publicationTargets], + ); + + return ( +
+ + + + +