AI Implementation feature(914): Admin Area Blog and Blog Page 1.00 #59

Merged
m0rph3us1987 merged 2 commits from feature-914-1784802404863 into dev 2026-07-23 10:37:53 +00:00
7 changed files with 149 additions and 85 deletions
-82
View File
@@ -1,82 +0,0 @@
# Fix Plan: TS2459 PostFormSubmitPayload not exported
## 1. Root Cause
`frontend/src/app/features/admin/blog/blog-form.pure.ts` declares `PostFormSubmitPayload` and `PostFormMode` as exports, but `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` only **imports** them — it does not re-export them.
`frontend/src/app/features/admin/blog/blog.component.ts:10` imports `PostFormSubmitPayload` from `./blog-form-modal.component`, which has no such export. Under `ng build --configuration production` (which uses `compilationMode: "full"` Angular strict template/type-check), this is a hard `TS2459` error and the bundle fails.
The existing codebase convention is the opposite: `category-form-modal.component.ts` declares `CategoryFormSubmit` / `CategoryFormMode` **inside** the modal file and consumers (`categories.component.ts`) import them from the modal component directly. My initial layout diverged from that pattern by colocating the modal-output types in the `.pure.ts` module.
## 2. Fix (chosen approach: match the existing `CategoryFormSubmit` convention)
Move the two UI-emit types out of `blog-form.pure.ts` and into `blog-form-modal.component.ts`, then update the two importers.
### 2.1 `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` (modify)
Add at the top (right after the existing imports from `./blog-form.pure`):
```ts
export type PostFormMode = 'create' | 'edit';
export interface PostFormSubmitPayload {
mode: PostFormMode;
title: string;
bodyMd: string;
action: 'draft' | 'publish';
}
```
Remove these two names from the import list at line 19-20:
```ts
// before
import {
PostFormGroup,
PostFormMode, // ← remove
PostFormSubmitPayload, // ← remove
buildPostBody,
syncPostForm,
validatePostForm,
} from './blog-form.pure';
// after
import {
PostFormGroup,
buildPostBody,
syncPostForm,
validatePostForm,
} from './blog-form.pure';
```
`PostFormGroup` stays imported from the `.pure.ts` module (it is the pure form-shape type, used internally by the modal).
### 2.2 `frontend/src/app/features/admin/blog/blog-form.pure.ts` (modify)
Remove the two declarations (lines 4 and 11-16) so the pure module no longer leaks UI-emit types:
- Delete `export type PostFormMode = 'create' | 'edit';`
- Delete the entire `export interface PostFormSubmitPayload { ... }` block.
Keep the rest of the file unchanged:
- `PostFormGroup`
- `MAX_TITLE_LENGTH` / `MAX_BODY_LENGTH`
- `validatePostForm`, `syncPostForm`, `buildPostBody`, `statusBadgeClass`, `statusLabel`
### 2.3 No other edits required
- `frontend/src/app/features/admin/blog/blog.component.ts` already imports `PostFormSubmitPayload` from `./blog-form-modal.component` — after step 2.1 that import resolves correctly. No change.
- `tests/frontend/blog-admin-form.spec.ts` does **not** import `PostFormMode` or `PostFormSubmitPayload` (verified — only `PostFormGroup`, `buildPostBody`, `statusBadgeClass`, `statusLabel`, `syncPostForm`, `validatePostForm`). No change.
- `tests/frontend/blog-page.spec.ts` is unaffected.
## 3. Verification
After the fix:
1. `cd /repo && npx ng build --configuration production` must succeed (the original failing command).
2. `cd /repo && npm test` must continue to pass (89 suites, 678 tests).
3. The `BlogFormModalComponent.submit` output still emits the same `PostFormSubmitPayload` shape — `blog.component.ts:onFormSubmit` is unchanged.
## 4. Why this fix over the alternative
Alternative considered: change the import in `blog.component.ts` from `'./blog-form-modal.component'` to `'./blog-form.pure'`. This is a one-line change but it diverges from the established `CategoryFormSubmit` pattern (modal-emit types live with the modal), and it forces every future consumer of the form payload to import from the `.pure.ts` module instead of the modal. The chosen fix aligns this feature with the rest of the codebase and keeps the modal's public API self-contained.
+65
View File
@@ -0,0 +1,65 @@
# Implementation Plan: Job 914 — Escape must close the New post overlay without saving
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Angular 17+ standalone components, `ChangeDetectionStrategy.OnPush`, signal inputs (`input()`), signal outputs (`output()`), the new `@if` control flow, and `effect()` for reactive side effects.
- Backend is NestJS with TypeORM; out of scope for this Job (no DB/API changes).
- The frontend admin blog feature lives under `frontend/src/app/features/admin/blog/`. The pure helpers live in `blog-form.pure.ts`; the modal is `blog-form-modal.component.ts`; the smart page is `blog.component.ts`.
- **Escape-to-close is the established convention for every modal in this codebase.** Every other modal uses `@HostListener('document:keydown.escape')` on the component class and emits its `cancel` output:
- `frontend/src/app/features/setup/setup-create-admin.component.ts:103`
- `frontend/src/app/features/admin/player-delete-modal.component.ts:61`
- `frontend/src/app/features/shell/change-password/change-password-modal.component.ts:156`
- `frontend/src/app/features/challenges/challenge-modal.component.ts:174`
- `frontend/src/app/features/admin/challenges/challenge-import-modal.component.ts:105`
- `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts:404`
- `frontend/src/app/features/admin/challenges/challenge-delete-modal.component.ts:56`
- `frontend/src/app/features/landing/landing.component.ts:102`
- The very similar `change-password-modal.component.ts:156159` guards the handler with `if (this.open() && !this.submitting()) this.onCancel();`. We will mirror that exact contract for the blog form modal (swapping `submitting()` for `saving()`).
- **The blog form modal is the only modal missing this listener.** This is the bug described in the Job.
- **Data Layer:** No database changes — this is purely a frontend modal interaction fix.
- **Test Framework & Structure:**
- Jest 29 with `ts-jest`, frontend project runs in `jsdom` (`tests/jest.config.js`).
- Single test entry: `npm test` runs both backend and frontend projects; `npm run test:frontend` runs only frontend.
- Tests live under `tests/frontend/` (front-end) and `tests/backend/` (back-end). Source code lives under `frontend/src/app/` and `backend/src/`. Tests must NEVER live inside source folders.
- Existing frontend tests are predominantly pure-helper tests (no TestBed). The `challenge-card-accessibility.spec.ts` pattern uses a `readFileSync` source-text assertion against the component template, which is the safest pattern for testing that a `@HostListener` is wired into the component without standing up the full Angular runtime.
- **Required Tools & Dependencies:** None. No new packages, globals, or `setup.sh` changes are needed — `@angular/core` already exports `HostListener`, and Jest/jsdom is already configured.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` — add the `@HostListener('document:keydown.escape')` handler that calls the existing `onCancel()` when the modal is open and not currently saving.
- **To Create:**
- `tests/frontend/blog-form-modal-escape.spec.ts` — minimal, focused regression test that verifies the Escape listener is wired and routed correctly. Mirrors the existing source-text snapshot pattern from `tests/frontend/challenge-card-accessibility.spec.ts` so it stays free of TestBed/Angular runtime setup and runs instantly under `npm test`.
## 3. Proposed Changes
1. **Add an Escape handler to `BlogFormModalComponent`** in `frontend/src/app/features/admin/blog/blog-form-modal.component.ts`:
- Import `HostListener` from `@angular/core` alongside the existing `ChangeDetectionStrategy`, `ChangeDetectorRef`, `Component`, `DestroyRef`, `effect`, `inject`, `input`, `output`, `signal` imports.
- Add the following method on the class (mirroring `change-password-modal.component.ts:156159`):
```ts
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.saving()) this.onCancel();
}
```
- This reuses the existing `onCancel()` (`blog-form-modal.component.ts:210`) which already emits `cancel`. The parent `AdminBlogComponent.closeModal()` (`blog.component.ts:188`) clears `modalMode`, `editing`, `formError`, `saving`, etc., so no post is created or modified.
- Rationale for the `open() && !saving()` guard: the modal is conditionally rendered via `@if (open())` in the template, so the listener will fire when the modal is mounted regardless of `open()`'s value; we explicitly check `open()` for safety and parity with `change-password-modal.component.ts`, and we skip the handler while `saving()` is true so an in-flight create/publish cannot be cancelled mid-request (the existing `Publish`/`Save draft` buttons remain `[disabled]` while saving and the parent also blocks the toolbar via `actionInFlight`).
- No template, styles, or HTML attribute changes are needed. The `Cancel` button, backdrop click, and the new Escape keypress all flow through the same `onCancel()` → `cancel` output.
2. **No backend / API changes.** The bug is purely a missing keyboard handler on the client. The REST endpoints (`POST /api/v1/admin/blog/posts`, `PATCH /api/v1/admin/blog/posts/:id`) are already correct; the failing test scenario was a *frontend* interaction that never called the API.
3. **No `setup.sh` / dependency changes.** `HostListener` is part of `@angular/core`, which is already a dependency.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/blog-form-modal-escape.spec.ts` (new file, in the dedicated `tests/frontend/` folder, runnable via `npm test` from the repo root).
- **Mocking Strategy:** No runtime mocking required. The test uses the same source-text assertion pattern as `tests/frontend/challenge-card-accessibility.spec.ts`: read `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` once with `fs.readFileSync` and assert that:
1. `HostListener` is imported from `@angular/core` (presence of the symbol in the import block).
2. The exact decorator `@HostListener('document:keydown.escape')` is present on the class.
3. The decorated handler method calls `this.onCancel()`.
4. The handler is guarded by `this.open()` and `this.saving()` (to prevent Escape from cancelling an in-flight save, matching the `change-password-modal` contract).
5. The existing `onCancel()` method still emits `cancel` (regression guard that the wiring remains intact).
- The test must import `'@angular/compiler'` at the top for parity with the rest of the frontend suite (see `tests/frontend/blog-admin-form.spec.ts:1`).
- Avoid standing up TestBed, ComponentFixture, or any DOM/jest-environment-jsdom behavior — keep the test pure and instant, per the `IMPORTANT RULE CONCERNING TESTS` instructions.
- All tests must pass via `npm test` (or `npm run test:frontend`) inside the container without any UI/browser interaction.
+1 -1
View File
@@ -50,7 +50,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `BlogPage` | `frontend/src/app/features/blog/blog.page.ts` | Smart `/blog` page that loads published posts and derives loading, error, empty, and list states. | | `BlogPage` | `frontend/src/app/features/blog/blog.page.ts` | Smart `/blog` page that loads published posts and derives loading, error, empty, and list states. |
| `BlogPresenterComponent` | `frontend/src/app/features/blog/blog-presenter.component.ts` | Shared title/date/sanitized-Markdown renderer used by `/blog` and `/login`. | | `BlogPresenterComponent` | `frontend/src/app/features/blog/blog-presenter.component.ts` | Shared title/date/sanitized-Markdown renderer used by `/blog` and `/login`. |
| `AdminBlogComponent` | `frontend/src/app/features/admin/blog/blog.component.ts` | Smart `/admin/blog` page coordinating list refresh and create/edit/delete modal state. | | `AdminBlogComponent` | `frontend/src/app/features/admin/blog/blog.component.ts` | Smart `/admin/blog` page coordinating list refresh and create/edit/delete modal state. |
| `BlogFormModalComponent` / `BlogDeleteModalComponent` | `frontend/src/app/features/admin/blog/` | Admin Markdown editor with live preview and destructive-action confirmation. | | `BlogFormModalComponent` / `BlogDeleteModalComponent` | `frontend/src/app/features/admin/blog/` | Admin Markdown editor with live preview and destructive-action confirmation. `BlogFormModalComponent` also installs a `@HostListener('document:keydown.escape')` handler that closes the modal via `onCancel()` when the modal is open and not currently saving (mirrors the `change-password-modal` Escape contract). |
# Authenticated SSE wiring # Authenticated SSE wiring
+9 -1
View File
@@ -3,7 +3,7 @@ type: guide
title: Blog Publishing and Reading title: Blog Publishing and Reading
description: How administrators create, edit, publish, and delete Markdown posts and how users read published posts. description: How administrators create, edit, publish, and delete Markdown posts and how users read published posts.
tags: [guide, blog, admin, markdown, tester] tags: [guide, blog, admin, markdown, tester]
timestamp: 2026-07-23T10:12:24Z timestamp: 2026-07-23T10:36:45Z
--- ---
# User views # User views
@@ -34,6 +34,14 @@ Both views use `BlogPresenterComponent` to show the post title, publication date
* Click the trash action to open **Delete post**. The confirmation includes the title and warns that deletion cannot be undone. * Click the trash action to open **Delete post**. The confirmation includes the title and warns that deletion cannot be undone.
* Click **Delete** to remove the post. The table refreshes and displays `Post deleted.`. Click **Cancel** or the modal backdrop to leave the post unchanged. * Click **Delete** to remove the post. The table refreshes and displays `Post deleted.`. Click **Cancel** or the modal backdrop to leave the post unchanged.
# Closing modals with Escape
Both the **New post / Edit post** modal and the **Delete post** confirmation honor the platform-wide Escape-to-close convention:
* Pressing **Escape** while the modal is open closes it without saving or deleting.
* Escape is suppressed while a save/publish request is in flight (the modal stays open until the current operation completes), so an in-flight create or update cannot be silently cancelled by an accidental keypress.
* Escape, the **Cancel** / **Delete** buttons, and clicking the modal backdrop all close the modal via the same `onCancel()``cancel` output flow; no partial post is ever created or modified when Escape is pressed.
# Validation and expected errors # Validation and expected errors
| Input or operation | Expected behavior | | Input or operation | Expected behavior |
+1 -1
View File
@@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and
signed-in blog views. signed-in blog views.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T10:12:24Z. they need. Last regenerated 2026-07-23T10:36:45Z.
# Architecture # Architecture
@@ -3,6 +3,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
HostListener,
effect, effect,
inject, inject,
input, input,
@@ -211,6 +212,11 @@ export class BlogFormModalComponent {
this.cancel.emit(); this.cancel.emit();
} }
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.saving()) this.onCancel();
}
onSubmit(action: 'draft' | 'publish'): void { onSubmit(action: 'draft' | 'publish'): void {
this.titleTouched.set(true); this.titleTouched.set(true);
this.bodyTouched.set(true); this.bodyTouched.set(true);
@@ -0,0 +1,67 @@
import '@angular/compiler';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const sourcePath = resolve(
__dirname,
'../../frontend/src/app/features/admin/blog/blog-form-modal.component.ts',
);
const source = readFileSync(sourcePath, 'utf8');
describe('BlogFormModalComponent — Escape-to-close contract', () => {
it('imports HostListener from @angular/core', () => {
const importBlock = source.match(
/import\s*\{[\s\S]*?\}\s*from\s*'@angular\/core';/,
);
expect(importBlock).not.toBeNull();
expect(importBlock![0]).toMatch(/\bHostListener\b/);
});
it('registers a @HostListener for document:keydown.escape', () => {
expect(source).toMatch(
/@HostListener\(\s*['"]document:keydown\.escape['"]\s*\)/,
);
});
it('guards the Escape handler with open() so a closed modal is a no-op', () => {
const handler = extractEscapeHandler(source);
expect(handler).not.toBeNull();
expect(handler).toMatch(/this\.open\(\)/);
});
it('guards the Escape handler against in-flight saves (no cancel while saving)', () => {
const handler = extractEscapeHandler(source);
expect(handler).not.toBeNull();
expect(handler).toMatch(/!this\.saving\(\)/);
});
it('routes Escape to the existing onCancel() so the parent closes the modal without saving', () => {
const handler = extractEscapeHandler(source);
expect(handler).not.toBeNull();
expect(handler).toMatch(/this\.onCancel\(\)/);
});
it('keeps the existing onCancel() emit intact as a regression guard', () => {
const onCancel = extractMethod(source, 'onCancel');
expect(onCancel).not.toBeNull();
expect(onCancel).toMatch(/this\.cancel\.emit\(\s*\)/);
});
});
function extractEscapeHandler(src: string): string | null {
// Capture the onEscape handler body — must contain the guard + onCancel call.
const m = src.match(
/@HostListener\(\s*['"]document:keydown\.escape['"]\s*\)\s*onEscape\s*\(\s*\)\s*:\s*void\s*\{([^}]*)\}/,
);
return m ? m[1] : null;
}
function extractMethod(src: string, name: string): string | null {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match the method header (with optional TypeScript return-type annotation) and capture the body.
const re = new RegExp(
`(?:public\\s+|private\\s+|protected\\s+)?${escaped}\\s*\\([^)]*\\)\\s*(?::\\s*[A-Za-z_<>|[\\]\\s,]+)?\\s*\\{([\\s\\S]*?)\\n\\s*\\}`,
);
const m = src.match(re);
return m ? m[1] : null;
}