Angular Admin CRUD — Post Create, Edit and Delete

📋 Table of Contents
  1. Admin Post Management
  2. Common Mistakes

The admin CRUD interface brings together the full-stack patterns — the reactive post form (Chapter 55), optimistic concurrency (Chapter 68), Material components (Chapter 58), and the auth guard (Chapter 72) — into a cohesive admin workflow. The key integration challenge is the optimistic concurrency pattern: the Angular form must send the current RowVersion as an If-Match header, handle 412 Precondition Failed gracefully, and reload the latest data if a conflict is detected.

Admin Post Management

// ── PostsApiService — CRUD with optimistic concurrency ────────────────────
@Injectable({ providedIn: 'root' })
export class PostsApiService extends ApiService {

  // Admin: get all posts (not just published)
  getAll(page = 1, size = 10, status?: string): Observable<PagedResult<PostSummaryDto>> {
    return this.get('/api/admin/posts', { page, size, ...(status && { status }) });
  }

  create(request: CreatePostRequest): Observable<PostDto> {
    return this.post('/api/posts', request);
  }

  // ETag header for optimistic concurrency
  update(id: number, request: UpdatePostRequest, etag: string): Observable<PostDto> {
    return this.http.put<PostDto>(this.url(`/api/posts/${id}`), request, {
      headers: { 'If-Match': etag },
      withCredentials: true,
    }).pipe(catchError(err => this.handleError(err)));
  }

  delete(id: number): Observable<void> {
    return this.delete(`/api/posts/${id}`);
  }
}

// ── PostFormComponent — shared create/edit with concurrency ───────────────
@Component({ standalone: true, template: `...` })
export class PostFormComponent implements OnInit, HasUnsavedChanges {
  @Input() postId?: number;

  private api        = inject(PostsApiService);
  private router     = inject(Router);
  private notify     = inject(NotificationService);
  private destroyRef = inject(DestroyRef);

  isSaving        = signal(false);
  serverErrors    = signal<Record<string, string[]>>({});
  private etag    = '';   // current RowVersion from the API

  form = inject(FormBuilder).nonNullable.group({
    title:   ['', [Validators.required, Validators.minLength(5)]],
    slug:    ['', [Validators.required, slugFormatValidator()]],
    body:    ['', Validators.required],
    excerpt: [''],
    tags:    inject(FormBuilder).array([inject(FormBuilder).nonNullable.control('')]),
  });

  hasUnsavedChanges() { return this.form.dirty; }

  ngOnInit() {
    if (this.postId) {
      this.api.getById(this.postId).subscribe(post => {
        this.form.patchValue({ title: post.title, slug: post.slug,
                               body: post.body, excerpt: post.excerpt ?? '' });
        // Store ETag for concurrency check
        this.etag = post.etag ?? '';
        this.form.markAsPristine();
      });
    }
  }

  onSave(status: 'draft' | 'published'): void {
    if (status === 'published' && this.form.invalid) {
      this.form.markAllAsTouched(); return;
    }
    this.isSaving.set(true);
    const data = { ...this.form.getRawValue(), status };

    const request$ = this.postId
      ? this.api.update(this.postId, data, this.etag)
      : this.api.create(data);

    request$.subscribe({
      next: post => {
        this.form.markAsPristine();
        this.notify.success(this.postId ? 'Post updated!' : 'Post created!');
        this.router.navigate(['/admin/posts']);
      },
      error: (err: ApiError) => {
        this.isSaving.set(false);
        if (err.status === 412) {
          // Concurrency conflict — reload the latest version
          this.notify.warn('This post was modified by someone else. Reloading...');
          this.api.getById(this.postId!).subscribe(post => {
            this.etag = post.etag ?? '';
            // Don't overwrite user's changes — show them the conflict
            this.serverErrors.set({ _conflict: ['Post has been modified since you started editing.'] });
          });
        } else if (err.status === 400 || err.status === 422) {
          this.serverErrors.set(err.errors);
        }
      },
    });
  }
}
Note: The ETag-based concurrency pattern (If-Match header) is the HTTP-standard approach for optimistic concurrency. The server returns the current RowVersion as an ETag in the GET response; the client sends it back in the If-Match header on PUT; the server checks if the ETag matches the current RowVersion and returns 412 if it does not. This is more idiomatic than custom request/response fields and works naturally with browser caching and HTTP standards.
Tip: On a 412 conflict, show the user a meaningful message rather than a generic error. The best UX for concurrency conflicts is to: (1) notify the user that the data changed, (2) show the current server version alongside their changes, (3) let them choose to overwrite or merge. For the BlogApp’s admin (low-concurrency, single-user editing patterns), simply reloading the latest data and informing the user is sufficient. Complex merge UI is only worth building for collaborative real-time editing scenarios.
Warning: The hasUnsavedChanges() method checks this.form.dirty. After a successful save, always call this.form.markAsPristine() to clear the dirty state — otherwise the unsaved changes guard triggers even after a successful save when the user tries to navigate away. Similarly, after loading existing data into the form (patchValue), call markAsPristine() so the initial data load doesn’t count as an “unsaved change.”

Common Mistakes

Mistake 1 — Not marking form as pristine after save (unsaved changes guard triggers incorrectly)

❌ Wrong — successful save doesn’t call markAsPristine(); guard shows “unsaved changes” warning after saving.

✅ Correct — always this.form.markAsPristine() after a successful save before navigating away.

Mistake 2 — Ignoring 412 conflict (silently overwrites other user’s changes)

❌ Wrong — retry the save without reloading on 412; sends the stale ETag again; server returns 412 again; infinite loop.

✅ Correct — on 412: reload latest data, update stored ETag, inform user of the conflict.

🧠 Test Yourself

User A and User B both open the same post for editing. User A saves first (RowVersion: 0x216). User B tries to save (sends If-Match: 0x215, the old RowVersion). What HTTP status code does User B receive?