AI Implementation feature(914): Admin Area Blog and Blog Page 1.00 (#59)
This commit was merged in pull request #59.
This commit is contained in:
@@ -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.
|
||||
@@ -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:156–159` 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:156–159`):
|
||||
```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.
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
|
||||
+9
-1
@@ -3,7 +3,7 @@ type: guide
|
||||
title: Blog Publishing and Reading
|
||||
description: How administrators create, edit, publish, and delete Markdown posts and how users read published posts.
|
||||
tags: [guide, blog, admin, markdown, tester]
|
||||
timestamp: 2026-07-23T10:12:24Z
|
||||
timestamp: 2026-07-23T10:36:45Z
|
||||
---
|
||||
|
||||
# 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 **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
|
||||
|
||||
| Input or operation | Expected behavior |
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and
|
||||
signed-in blog views.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
HostListener,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
@@ -211,6 +212,11 @@ export class BlogFormModalComponent {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.open() && !this.saving()) this.onCancel();
|
||||
}
|
||||
|
||||
onSubmit(action: 'draft' | 'publish'): void {
|
||||
this.titleTouched.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;
|
||||
}
|
||||
Reference in New Issue
Block a user