fix: category slug-uniqueness check fails closed on API error
isSlugTaken previously caught network/API errors and returned false, letting the save proceed as if the slug were free. Now the error propagates and blocks save via a distinct slugCheckError state, surfaced in the category form with its own hint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@
|
|||||||
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
||||||
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
|
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugCheckError ? ('adminCategories.slugCheckError' | translate) : (slugTaken ? ('adminCategories.slugTaken' | translate) : null)">
|
||||||
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
|
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class AdminCategoryFormComponent {
|
|||||||
@Input() breadcrumb: string[] = [];
|
@Input() breadcrumb: string[] = [];
|
||||||
@Input() children: AdminCategory[] = [];
|
@Input() children: AdminCategory[] = [];
|
||||||
@Input() slugTaken = false;
|
@Input() slugTaken = false;
|
||||||
|
@Input() slugCheckError = false;
|
||||||
@Input() locales: string[] = ['en'];
|
@Input() locales: string[] = ['en'];
|
||||||
@Input() mode: 'create' | 'edit' = 'create';
|
@Input() mode: 'create' | 'edit' = 'create';
|
||||||
@Input() health!: AdminCategoryHealth;
|
@Input() health!: AdminCategoryHealth;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
import { catchError, of, take } from 'rxjs';
|
||||||
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
|
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
|
||||||
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
||||||
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
|
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
|
||||||
@@ -135,6 +135,7 @@ export class AdminCategoriesFacade {
|
|||||||
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
||||||
readonly dirty = signal(false);
|
readonly dirty = signal(false);
|
||||||
readonly slugTaken = signal(false);
|
readonly slugTaken = signal(false);
|
||||||
|
readonly slugCheckError = signal(false);
|
||||||
private savedSnapshot: string | null = null;
|
private savedSnapshot: string | null = null;
|
||||||
private draftStorageKey: string | null = null;
|
private draftStorageKey: string | null = null;
|
||||||
|
|
||||||
@@ -305,15 +306,26 @@ export class AdminCategoriesFacade {
|
|||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft || !draft.slug) {
|
if (!draft || !draft.slug) {
|
||||||
this.slugTaken.set(false);
|
this.slugTaken.set(false);
|
||||||
|
this.slugCheckError.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
|
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
|
||||||
.subscribe(taken => this.slugTaken.set(taken));
|
take(1),
|
||||||
|
catchError(() => {
|
||||||
|
this.slugCheckError.set(true);
|
||||||
|
return of(true);
|
||||||
|
})
|
||||||
|
).subscribe(taken => {
|
||||||
|
this.slugTaken.set(taken);
|
||||||
|
if (!taken) {
|
||||||
|
this.slugCheckError.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDraft(publish: boolean): void {
|
saveDraft(publish: boolean): void {
|
||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft || this.slugTaken()) return;
|
if (!draft || this.slugTaken() || this.slugCheckError()) return;
|
||||||
|
|
||||||
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
||||||
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
selector: 'app-admin-category-editor-page',
|
selector: 'app-admin-category-editor-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminCategoryFormComponent, TranslatePipe],
|
imports: [AdminCategoryFormComponent, TranslatePipe],
|
||||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
||||||
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ export class AdminCategoriesApiGateway implements AdminCategoriesGateway {
|
|||||||
params = params.set('excludingId', excludingId);
|
params = params.set('excludingId', excludingId);
|
||||||
}
|
}
|
||||||
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
||||||
map(response => response.taken),
|
map(response => response.taken)
|
||||||
catchError(() => of(false))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1533,6 +1533,7 @@ export const en: Translations = {
|
|||||||
title: 'Title',
|
title: 'Title',
|
||||||
slug: 'URL slug',
|
slug: 'URL slug',
|
||||||
slugTaken: 'This slug is already used by another category.',
|
slugTaken: 'This slug is already used by another category.',
|
||||||
|
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
|
||||||
parent: 'Parent category',
|
parent: 'Parent category',
|
||||||
noParent: 'No parent (top level)',
|
noParent: 'No parent (top level)',
|
||||||
icon: 'Icon',
|
icon: 'Icon',
|
||||||
|
|||||||
@@ -1528,6 +1528,7 @@ export const hy: Translations = {
|
|||||||
title: 'Անուն',
|
title: 'Անուն',
|
||||||
slug: 'URL հասցե',
|
slug: 'URL հասցե',
|
||||||
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
||||||
|
slugCheckError: 'Չհաջողվեց ստուգել հասցեի եզակիությունը։ Փորձեք կրկին՝ նախքան պահպանելը։',
|
||||||
parent: 'Ծնող կատեգորիա',
|
parent: 'Ծնող կատեգորիա',
|
||||||
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
||||||
icon: 'Պատկերակ',
|
icon: 'Պատկերակ',
|
||||||
|
|||||||
@@ -1528,6 +1528,7 @@ export const ru: Translations = {
|
|||||||
title: 'Название',
|
title: 'Название',
|
||||||
slug: 'URL-адрес',
|
slug: 'URL-адрес',
|
||||||
slugTaken: 'Этот адрес уже используется другой категорией.',
|
slugTaken: 'Этот адрес уже используется другой категорией.',
|
||||||
|
slugCheckError: 'Не удалось проверить уникальность адреса. Повторите попытку перед сохранением.',
|
||||||
parent: 'Родительская категория',
|
parent: 'Родительская категория',
|
||||||
noParent: 'Без родителя (верхний уровень)',
|
noParent: 'Без родителя (верхний уровень)',
|
||||||
icon: 'Иконка',
|
icon: 'Иконка',
|
||||||
|
|||||||
@@ -1541,6 +1541,7 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
slugTaken: string;
|
slugTaken: string;
|
||||||
|
slugCheckError: string;
|
||||||
parent: string;
|
parent: string;
|
||||||
noParent: string;
|
noParent: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user