improvements are done
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading-skeleton',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="skeleton-wrapper">
|
||||
@if (type === 'tree') {
|
||||
<div class="skeleton-tree">
|
||||
@for (item of [1,2,3,4,5]; track item) {
|
||||
<div class="skeleton-tree-item">
|
||||
<div class="skeleton-circle"></div>
|
||||
<div class="skeleton-line" [style.width]="getRandomWidth()"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (type === 'card') {
|
||||
<div class="skeleton-card">
|
||||
<div class="skeleton-image"></div>
|
||||
<div class="skeleton-text">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (type === 'list') {
|
||||
<div class="skeleton-list">
|
||||
@for (item of [1,2,3]; track item) {
|
||||
<div class="skeleton-list-item">
|
||||
<div class="skeleton-line"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (type === 'form') {
|
||||
<div class="skeleton-form">
|
||||
@for (item of [1,2,3,4]; track item) {
|
||||
<div class="skeleton-field">
|
||||
<div class="skeleton-line short"></div>
|
||||
<div class="skeleton-input"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.skeleton-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -468px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 468px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-line,
|
||||
.skeleton-circle,
|
||||
.skeleton-image,
|
||||
.skeleton-input {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#f0f0f0 0%,
|
||||
#e0e0e0 20%,
|
||||
#f0f0f0 40%,
|
||||
#f0f0f0 100%
|
||||
);
|
||||
background-size: 800px 104px;
|
||||
animation: shimmer 1.5s infinite linear;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 16px;
|
||||
width: 100%;
|
||||
|
||||
&.short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-list-item {
|
||||
padding: 1rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.skeleton-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-input {
|
||||
height: 56px;
|
||||
width: 100%;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class LoadingSkeletonComponent {
|
||||
@Input() type: 'tree' | 'card' | 'list' | 'form' = 'list';
|
||||
|
||||
getRandomWidth(): string {
|
||||
const widths = ['60%', '70%', '80%', '90%'];
|
||||
return widths[Math.floor(Math.random() * widths.length)];
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,6 @@ export interface Subcategory {
|
||||
img?: string;
|
||||
categoryId: string;
|
||||
itemCount?: number;
|
||||
subcategories?: Subcategory[];
|
||||
hasItems?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Comment {
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
author?: string;
|
||||
stars?: number;
|
||||
}
|
||||
|
||||
export interface ItemsListResponse {
|
||||
|
||||
@@ -235,7 +235,10 @@
|
||||
placeholder="e.g. Black">
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="addDescriptionField()">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="addDescriptionField()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Field
|
||||
</button>
|
||||
@@ -289,7 +292,18 @@
|
||||
@for (comment of item()!.comments; track comment.id) {
|
||||
<div class="comment-card">
|
||||
<div class="comment-header">
|
||||
<strong>{{ comment.author || 'Anonymous' }}</strong>
|
||||
<div>
|
||||
<strong>{{ comment.author || 'Anonymous' }}</strong>
|
||||
@if (comment.stars !== undefined && comment.stars !== null) {
|
||||
<span class="comment-stars">
|
||||
@for (star of [1,2,3,4,5]; track star) {
|
||||
<mat-icon class="star-icon" [class.filled]="star <= comment.stars!">
|
||||
{{ star <= comment.stars! ? 'star' : 'star_border' }}
|
||||
</mat-icon>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span class="comment-date">{{ comment.createdAt | date:'short' }}</span>
|
||||
</div>
|
||||
<p>{{ comment.text }}</p>
|
||||
|
||||
@@ -279,10 +279,32 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comment-stars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
.star-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #ffa726;
|
||||
|
||||
&.filled {
|
||||
color: #ff9800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
color: #999;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -15,7 +16,8 @@ import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { ApiService } from '../../services';
|
||||
import { Item, ItemDescriptionField } from '../../models';
|
||||
import { ValidationService } from '../../services/validation.service';
|
||||
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
@@ -42,10 +44,12 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
|
||||
})
|
||||
export class ItemEditorComponent implements OnInit {
|
||||
item = signal<Item | null>(null);
|
||||
subcategory = signal<Subcategory | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
itemId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
validationErrors = signal<Record<string, string>>({});
|
||||
|
||||
newTag = '';
|
||||
newDescKey = '';
|
||||
@@ -59,7 +63,8 @@ export class ItemEditorComponent implements OnInit {
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog
|
||||
private dialog: MatDialog,
|
||||
private validationService: ValidationService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -80,7 +85,8 @@ export class ItemEditorComponent implements OnInit {
|
||||
this.apiService.getItem(this.itemId()).subscribe({
|
||||
next: (item) => {
|
||||
this.item.set(item);
|
||||
this.loading.set(false);
|
||||
// Load subcategory to get allowed description fields
|
||||
this.loadSubcategory(item.subcategoryId);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load item', err);
|
||||
@@ -90,7 +96,75 @@ export class ItemEditorComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
loadSubcategory(subcategoryId: string) {
|
||||
this.apiService.getSubcategory(subcategoryId).subscribe({
|
||||
next: (subcategory) => {
|
||||
this.subcategory.set(subcategory);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load subcategory', err);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async buildCategoryPath(): Promise<string> {
|
||||
// Build path like: /category/subcategory/subsubcategory/item
|
||||
const item = this.item();
|
||||
if (!item) return '';
|
||||
|
||||
const pathSegments: string[] = [];
|
||||
let currentSubcategoryId = item.subcategoryId;
|
||||
|
||||
// Traverse up the subcategory hierarchy
|
||||
while (currentSubcategoryId) {
|
||||
try {
|
||||
const subcategory = await this.apiService.getSubcategory(currentSubcategoryId).toPromise();
|
||||
if (!subcategory) break;
|
||||
|
||||
pathSegments.unshift(subcategory.id); // Add to beginning
|
||||
|
||||
// Check if this subcategory has a parent subcategory or belongs to a category
|
||||
if (subcategory.categoryId && subcategory.categoryId.startsWith('cat')) {
|
||||
// This is directly under a category, add category and stop
|
||||
const category = await this.apiService.getCategory(subcategory.categoryId).toPromise();
|
||||
if (category) {
|
||||
pathSegments.unshift(category.id);
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
// This is under another subcategory, continue traversing
|
||||
currentSubcategoryId = subcategory.categoryId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error building path:', err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pathSegments.push(this.itemId());
|
||||
return '/' + pathSegments.join('/');
|
||||
}
|
||||
|
||||
onFieldChange(field: keyof Item, value: any) {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return;
|
||||
|
||||
// Validate the specific field
|
||||
const errors = this.validationService.validateItem({ [field]: value } as any);
|
||||
const currentErrors = { ...this.validationErrors() };
|
||||
|
||||
if (errors[field]) {
|
||||
currentErrors[field] = errors[field];
|
||||
this.validationErrors.set(currentErrors);
|
||||
this.snackBar.open(`Validation error: ${errors[field]}`, 'Close', { duration: 3000 });
|
||||
return;
|
||||
} else {
|
||||
delete currentErrors[field];
|
||||
this.validationErrors.set(currentErrors);
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('item', this.itemId(), field, value);
|
||||
|
||||
@@ -203,14 +277,32 @@ export class ItemEditorComponent implements OnInit {
|
||||
if (item && item.subcategoryId) {
|
||||
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
this.router.navigate(['/project', this.projectId()]);
|
||||
}
|
||||
}
|
||||
|
||||
previewInMarketplace() {
|
||||
async previewInMarketplace() {
|
||||
// Open marketplace in new tab with this item
|
||||
const marketplaceUrl = `http://localhost:4200/item/${this.itemId()}`;
|
||||
window.open(marketplaceUrl, '_blank');
|
||||
const item = this.item();
|
||||
const subcategory = this.subcategory();
|
||||
if (!item || !subcategory) {
|
||||
this.snackBar.open('Item data not loaded', 'Close', { duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the full category hierarchy path
|
||||
const path = await this.buildCategoryPath();
|
||||
if (!path) {
|
||||
this.snackBar.open('Unable to build preview URL', 'Close', { duration: 2000 });
|
||||
return;
|
||||
}
|
||||
const marketplaceUrl = `${environment.marketplaceUrl}${path}`;
|
||||
window.open(marketplaceUrl, '_blank');
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err);
|
||||
this.snackBar.open('Failed to generate preview URL', 'Close', { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
onImageDrop(event: CdkDragDrop<string[]>) {
|
||||
|
||||
@@ -177,13 +177,8 @@ export class ItemsListComponent implements OnInit {
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const subcategoryId = this.subcategoryId();
|
||||
if (subcategoryId) {
|
||||
// Navigate back to subcategory editor
|
||||
this.router.navigate(['/subcategory', subcategoryId]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
// Navigate back to the project view
|
||||
this.router.navigate(['/project', this.projectId()]);
|
||||
}
|
||||
|
||||
addItem() {
|
||||
|
||||
@@ -16,14 +16,12 @@
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
<app-loading-skeleton type="tree"></app-loading-skeleton>
|
||||
} @else {
|
||||
<div class="tree-container">
|
||||
@for (node of treeData(); track node.id) {
|
||||
<div class="tree-node">
|
||||
<div class="node-content category-node">
|
||||
<div class="node-content category-node" [class.selected]="selectedNodeId() === node.id">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleNode(node)"
|
||||
@@ -38,6 +36,14 @@
|
||||
</span>
|
||||
|
||||
<div class="node-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="addSubcategory(node, $event)"
|
||||
matTooltip="Add Subcategory"
|
||||
color="accent">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="node.visible"
|
||||
(change)="toggleVisibility(node, $event)"
|
||||
@@ -56,39 +62,7 @@
|
||||
</div>
|
||||
|
||||
@if (node.expanded && node.children?.length) {
|
||||
<div class="subcategories">
|
||||
@for (subNode of node.children; track subNode.id) {
|
||||
<div class="node-content subcategory-node">
|
||||
<span class="node-name" (click)="editNode(subNode, $event)">
|
||||
{{ subNode.name }}
|
||||
</span>
|
||||
|
||||
<div class="node-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="viewItems(subNode, $event)"
|
||||
matTooltip="View Items">
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="subNode.visible"
|
||||
(change)="toggleVisibility(subNode, $event)"
|
||||
color="primary"
|
||||
matTooltip="Toggle Visibility">
|
||||
</mat-slide-toggle>
|
||||
|
||||
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="subcategoryTree" [ngTemplateOutletContext]="{nodes: node.children, level: 1}"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -111,3 +85,67 @@
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
</div>
|
||||
|
||||
<ng-template #subcategoryTree let-nodes="nodes" let-level="level">
|
||||
<div class="subcategories" [style.padding-left.rem]="level * 1.5">
|
||||
@for (subNode of nodes; track subNode.id) {
|
||||
<div>
|
||||
<div class="node-content subcategory-node" [class.selected]="selectedNodeId() === subNode.id">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleNode(subNode)"
|
||||
[disabled]="!subNode.children?.length">
|
||||
<mat-icon>
|
||||
{{ subNode.children?.length ? (subNode.expanded ? 'expand_more' : 'chevron_right') : '' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<span class="node-name" (click)="editNode(subNode, $event)">
|
||||
{{ subNode.name }}
|
||||
</span>
|
||||
|
||||
<div class="node-actions">
|
||||
@if (!subNode.hasItems) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="addSubcategory(subNode, $event)"
|
||||
matTooltip="Add Subcategory"
|
||||
color="accent">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (subNode.hasItems) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="viewItems(subNode, $event)"
|
||||
matTooltip="View Items">
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="subNode.visible"
|
||||
(change)="toggleVisibility(subNode, $event)"
|
||||
color="primary"
|
||||
matTooltip="Toggle Visibility">
|
||||
</mat-slide-toggle>
|
||||
|
||||
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (subNode.expanded && subNode.children?.length) {
|
||||
<ng-container [ngTemplateOutlet]="subcategoryTree" [ngTemplateOutletContext]="{nodes: subNode.children, level: level + 1}"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
||||
@@ -64,16 +64,35 @@ mat-toolbar {
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: #bbdefb;
|
||||
border-left: 4px solid #1976d2;
|
||||
padding-left: calc(0.5rem - 4px);
|
||||
|
||||
&:hover {
|
||||
background-color: #90caf9;
|
||||
}
|
||||
}
|
||||
|
||||
&.category-node {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
background-color: #fafafa;
|
||||
|
||||
&.selected {
|
||||
background-color: #bbdefb;
|
||||
}
|
||||
}
|
||||
|
||||
&.subcategory-node {
|
||||
padding-left: 3rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: #fff;
|
||||
|
||||
&.selected {
|
||||
background-color: #bbdefb;
|
||||
padding-left: calc(3rem - 4px);
|
||||
}
|
||||
}
|
||||
|
||||
.node-name {
|
||||
|
||||
@@ -11,9 +11,12 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ApiService } from '../../services';
|
||||
import { ValidationService } from '../../services/validation.service';
|
||||
import { Category, Subcategory } from '../../models';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
interface CategoryNode {
|
||||
id: string;
|
||||
@@ -23,6 +26,8 @@ interface CategoryNode {
|
||||
expanded?: boolean;
|
||||
children?: CategoryNode[];
|
||||
categoryId?: string;
|
||||
parentId?: string;
|
||||
hasItems?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -39,7 +44,9 @@ interface CategoryNode {
|
||||
MatToolbarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
MatSnackBarModule,
|
||||
MatTooltipModule,
|
||||
LoadingSkeletonComponent
|
||||
],
|
||||
templateUrl: './project-view.component.html',
|
||||
styleUrls: ['./project-view.component.scss']
|
||||
@@ -50,13 +57,15 @@ export class ProjectViewComponent implements OnInit {
|
||||
categories = signal<Category[]>([]);
|
||||
loading = signal(true);
|
||||
treeData = signal<CategoryNode[]>([]);
|
||||
selectedNodeId = signal<string | null>(null);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private dialog: MatDialog,
|
||||
private snackBar: MatSnackBar
|
||||
private snackBar: MatSnackBar,
|
||||
private validationService: ValidationService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -65,6 +74,13 @@ export class ProjectViewComponent implements OnInit {
|
||||
this.loadProject();
|
||||
this.loadCategories();
|
||||
});
|
||||
|
||||
// Track selected route
|
||||
this.router.events.subscribe(() => {
|
||||
const categoryId = this.route.children[0]?.snapshot.params['categoryId'];
|
||||
const subcategoryId = this.route.children[0]?.snapshot.params['subcategoryId'];
|
||||
this.selectedNodeId.set(subcategoryId || categoryId || null);
|
||||
});
|
||||
}
|
||||
|
||||
hasActiveRoute(): boolean {
|
||||
@@ -100,23 +116,45 @@ export class ProjectViewComponent implements OnInit {
|
||||
}
|
||||
|
||||
buildTree() {
|
||||
// Save current expanded state
|
||||
const expandedState = new Map<string, boolean>();
|
||||
const saveExpandedState = (nodes: CategoryNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.expanded) {
|
||||
expandedState.set(node.id, true);
|
||||
}
|
||||
if (node.children) {
|
||||
saveExpandedState(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
saveExpandedState(this.treeData());
|
||||
|
||||
// Build new tree
|
||||
const tree: CategoryNode[] = this.categories().map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
type: 'category' as const,
|
||||
visible: cat.visible,
|
||||
expanded: false,
|
||||
children: (cat.subcategories || []).map(sub => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: 'subcategory' as const,
|
||||
visible: sub.visible,
|
||||
categoryId: cat.id
|
||||
}))
|
||||
expanded: expandedState.has(cat.id),
|
||||
children: this.buildSubcategoryTree(cat.subcategories || [], cat.id, expandedState)
|
||||
}));
|
||||
this.treeData.set(tree);
|
||||
}
|
||||
|
||||
buildSubcategoryTree(subcategories: Subcategory[], parentId: string, expandedState?: Map<string, boolean>): CategoryNode[] {
|
||||
return subcategories.map(sub => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: 'subcategory' as const,
|
||||
visible: sub.visible,
|
||||
expanded: expandedState?.has(sub.id) || false,
|
||||
parentId: parentId,
|
||||
hasItems: sub.hasItems,
|
||||
children: this.buildSubcategoryTree(sub.subcategories || [], sub.id, expandedState)
|
||||
}));
|
||||
}
|
||||
|
||||
toggleNode(node: CategoryNode) {
|
||||
node.expanded = !node.expanded;
|
||||
}
|
||||
@@ -168,13 +206,61 @@ export class ProjectViewComponent implements OnInit {
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
// Validate before creating
|
||||
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMsg = Object.values(errors).join(', ');
|
||||
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
this.apiService.createCategory(this.projectId(), result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to create category', 'Close', { duration: 3000 });
|
||||
this.snackBar.open(err.message || 'Failed to create category', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSubcategory(parentNode: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
data: {
|
||||
title: 'Create New Subcategory',
|
||||
type: 'subcategory',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'id', label: 'ID', type: 'text', required: true, hint: 'Used for routing' },
|
||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
// Validate before creating
|
||||
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMsg = Object.values(errors).join(', ');
|
||||
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const parentId = parentNode.type === 'category' ? parentNode.id : parentNode.id;
|
||||
this.apiService.createSubcategory(parentId, result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open(err.message || 'Failed to create subcategory', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -184,12 +270,18 @@ export class ProjectViewComponent implements OnInit {
|
||||
deleteCategory(node: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const subCount = node.children?.length || 0;
|
||||
const message = subCount > 0
|
||||
? `Are you sure you want to delete "${node.name}"? This will also delete ${subCount} subcategory(ies) and all their items. This action cannot be undone.`
|
||||
: `Are you sure you want to delete "${node.name}"? This action cannot be undone.`;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Category',
|
||||
message: `Are you sure you want to delete "${node.name}"? This will also delete all subcategories and items.`,
|
||||
message: message,
|
||||
confirmText: 'Delete',
|
||||
warning: true
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -211,10 +303,23 @@ export class ProjectViewComponent implements OnInit {
|
||||
deleteSubcategory(node: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const childCount = node.children?.length || 0;
|
||||
const hasChildren = childCount > 0;
|
||||
const hasItems = node.hasItems;
|
||||
|
||||
let message = `Are you sure you want to delete "${node.name}"?`;
|
||||
if (hasChildren) {
|
||||
message += ` This will also delete ${childCount} nested subcategory(ies).`;
|
||||
}
|
||||
if (hasItems) {
|
||||
message += ' This will also delete all items.';
|
||||
}
|
||||
message += ' This action cannot be undone.';
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Subcategory',
|
||||
message: `Are you sure you want to delete "${node.name}"? This will also delete all items.`,
|
||||
message: message,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@if (!loading() && !error()) {
|
||||
<div class="projects-grid">
|
||||
@for (project of projects(); track project.id) {
|
||||
<mat-card class="project-card" (click)="openProject(project.id)">
|
||||
<mat-card class="project-card" [class.selected]="currentProjectId() === project.id" (click)="openProject(project.id)">
|
||||
<mat-card-header>
|
||||
@if (project.logoUrl) {
|
||||
<img [src]="project.logoUrl" [alt]="project.displayName" class="project-logo">
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border: 2px solid #1976d2;
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 16px rgba(25, 118, 210, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ProjectsDashboardComponent implements OnInit {
|
||||
projects = signal<Project[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
currentProjectId = signal<string | null>(null);
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
@@ -25,6 +26,22 @@ export class ProjectsDashboardComponent implements OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
this.loadProjects();
|
||||
|
||||
// Check if we're currently viewing a project
|
||||
const urlSegments = this.router.url.split('/');
|
||||
if (urlSegments[1] === 'project' && urlSegments[2]) {
|
||||
this.currentProjectId.set(urlSegments[2]);
|
||||
}
|
||||
|
||||
// Listen to route changes
|
||||
this.router.events.subscribe(() => {
|
||||
const segments = this.router.url.split('/');
|
||||
if (segments[1] === 'project' && segments[2]) {
|
||||
this.currentProjectId.set(segments[2]);
|
||||
} else {
|
||||
this.currentProjectId.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadProjects() {
|
||||
|
||||
@@ -40,7 +40,15 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>ID</mat-label>
|
||||
<input matInput [value]="subcategory()!.id" disabled>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="subcategory()!.id"
|
||||
(blur)="onFieldChange('id', subcategory()!.id)"
|
||||
required>
|
||||
<mat-hint>Used for routing - update carefully</mat-hint>
|
||||
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
|
||||
<mat-error>ID is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
|
||||
@@ -119,12 +119,8 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const sub = this.subcategory();
|
||||
if (sub && this.projectId()) {
|
||||
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
// Navigate back to the project view (close the editor)
|
||||
this.router.navigate(['/project', this.projectId()]);
|
||||
}
|
||||
|
||||
deleteSubcategory() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, Subject, timer } from 'rxjs';
|
||||
import { debounce, retry, catchError, tap } from 'rxjs/operators';
|
||||
import { debounce, retry, catchError, tap, map } from 'rxjs/operators';
|
||||
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||
import { MockDataService } from './mock-data.service';
|
||||
import { environment } from '../../environments/environment';
|
||||
@@ -76,9 +76,11 @@ export class ApiService {
|
||||
|
||||
// Subcategories
|
||||
getSubcategories(categoryId: string): Observable<Subcategory[]> {
|
||||
if (environment.useMockData) return this.mockService.getCategory(categoryId).pipe(
|
||||
tap(cat => cat.subcategories || [])
|
||||
) as any;
|
||||
if (environment.useMockData) {
|
||||
return this.mockService.getCategory(categoryId).pipe(
|
||||
map(cat => cat.subcategories || [])
|
||||
);
|
||||
}
|
||||
return this.http.get<Subcategory[]>(`${this.API_BASE}/categories/${categoryId}/subcategories`).pipe(
|
||||
retry(2),
|
||||
catchError(this.handleError)
|
||||
@@ -220,8 +222,51 @@ export class ApiService {
|
||||
}
|
||||
|
||||
private handleError(error: any): Observable<never> {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client-side or network error
|
||||
errorMessage = `Network error: ${error.error.message}`;
|
||||
} else if (error.status) {
|
||||
// Backend returned an unsuccessful response code
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
errorMessage = error.error?.message || 'Invalid request';
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = 'Unauthorized. Please log in again.';
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = 'You do not have permission to perform this action';
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = 'Resource not found';
|
||||
break;
|
||||
case 409:
|
||||
errorMessage = error.error?.message || 'Conflict: Resource already exists or has conflicts';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = error.error?.message || 'Validation failed';
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = 'Service unavailable. Please try again later.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.error?.message || `Error: ${error.status} - ${error.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Error:', {
|
||||
message: errorMessage,
|
||||
status: error.status,
|
||||
error: error.error,
|
||||
url: error.url
|
||||
});
|
||||
|
||||
throw { message: errorMessage, status: error.status, originalError: error };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './api.service';
|
||||
export * from './validation.service';
|
||||
export * from './toast.service';
|
||||
|
||||
@@ -248,52 +248,105 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
getSubcategory(subcategoryId: string): Observable<Subcategory> {
|
||||
let sub: Subcategory | undefined;
|
||||
for (const cat of this.categories) {
|
||||
sub = cat.subcategories?.find(s => s.id === subcategoryId);
|
||||
if (sub) break;
|
||||
}
|
||||
const sub = this.findSubcategoryById(subcategoryId);
|
||||
return of(sub!).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateSubcategory(subcategoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||
let sub: Subcategory | undefined;
|
||||
for (const cat of this.categories) {
|
||||
sub = cat.subcategories?.find(s => s.id === subcategoryId);
|
||||
if (sub) {
|
||||
Object.assign(sub, data);
|
||||
break;
|
||||
}
|
||||
const sub = this.findSubcategoryById(subcategoryId);
|
||||
if (sub) {
|
||||
Object.assign(sub, data);
|
||||
}
|
||||
return of(sub!).pipe(delay(300));
|
||||
}
|
||||
|
||||
createSubcategory(categoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||
const cat = this.categories.find(c => c.id === categoryId)!;
|
||||
createSubcategory(parentId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||
// Check if parent already has items
|
||||
const parentSubcategory = this.findSubcategoryById(parentId);
|
||||
if (parentSubcategory?.hasItems) {
|
||||
throw new Error('Cannot create subcategory: parent already has items');
|
||||
}
|
||||
|
||||
const newSub: Subcategory = {
|
||||
id: `sub${Date.now()}`,
|
||||
id: data.id || `sub${Date.now()}`,
|
||||
name: data.name || 'New Subcategory',
|
||||
visible: data.visible ?? true,
|
||||
priority: data.priority || 99,
|
||||
img: data.img,
|
||||
categoryId,
|
||||
categoryId: parentId,
|
||||
itemCount: 0
|
||||
};
|
||||
if (!cat.subcategories) cat.subcategories = [];
|
||||
cat.subcategories.push(newSub);
|
||||
return of(newSub).pipe(delay(300));
|
||||
|
||||
// Try to find parent category first
|
||||
const cat = this.categories.find(c => c.id === parentId);
|
||||
if (cat) {
|
||||
if (!cat.subcategories) cat.subcategories = [];
|
||||
cat.subcategories.push(newSub);
|
||||
return of(newSub).pipe(delay(300));
|
||||
}
|
||||
|
||||
// If not a category, search for parent subcategory recursively
|
||||
const parent = this.findSubcategoryById(parentId);
|
||||
if (parent) {
|
||||
if (!parent.subcategories) parent.subcategories = [];
|
||||
parent.subcategories.push(newSub);
|
||||
return of(newSub).pipe(delay(300));
|
||||
}
|
||||
|
||||
// Parent not found
|
||||
throw new Error(`Parent with id ${parentId} not found`);
|
||||
}
|
||||
|
||||
private findSubcategoryById(id: string): Subcategory | null {
|
||||
for (const cat of this.categories) {
|
||||
const result = this.searchSubcategories(cat.subcategories || [], id);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private searchSubcategories(subcategories: Subcategory[], id: string): Subcategory | null {
|
||||
for (const sub of subcategories) {
|
||||
if (sub.id === id) return sub;
|
||||
if (sub.subcategories) {
|
||||
const result = this.searchSubcategories(sub.subcategories, id);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
deleteSubcategory(subcategoryId: string): Observable<void> {
|
||||
// Try to delete from category level
|
||||
for (const cat of this.categories) {
|
||||
const index = cat.subcategories?.findIndex(s => s.id === subcategoryId) ?? -1;
|
||||
if (index > -1) {
|
||||
cat.subcategories?.splice(index, 1);
|
||||
break;
|
||||
return of(void 0).pipe(delay(300));
|
||||
}
|
||||
// Try to delete from nested subcategories
|
||||
if (this.deleteFromSubcategories(cat.subcategories || [], subcategoryId)) {
|
||||
return of(void 0).pipe(delay(300));
|
||||
}
|
||||
}
|
||||
return of(void 0).pipe(delay(300));
|
||||
}
|
||||
|
||||
private deleteFromSubcategories(subcategories: Subcategory[], id: string): boolean {
|
||||
for (const sub of subcategories) {
|
||||
if (sub.subcategories) {
|
||||
const index = sub.subcategories.findIndex(s => s.id === id);
|
||||
if (index > -1) {
|
||||
sub.subcategories.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
if (this.deleteFromSubcategories(sub.subcategories, id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
|
||||
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
|
||||
@@ -354,12 +407,32 @@ export class MockDataService {
|
||||
subcategoryId
|
||||
};
|
||||
this.items.push(newItem);
|
||||
|
||||
// Mark subcategory as having items
|
||||
const subcategory = this.findSubcategoryById(subcategoryId);
|
||||
if (subcategory) {
|
||||
subcategory.hasItems = true;
|
||||
}
|
||||
|
||||
return of(newItem).pipe(delay(300));
|
||||
}
|
||||
|
||||
deleteItem(itemId: string): Observable<void> {
|
||||
const item = this.items.find(i => i.id === itemId);
|
||||
const index = this.items.findIndex(i => i.id === itemId);
|
||||
if (index > -1) this.items.splice(index, 1);
|
||||
if (index > -1) {
|
||||
const subcategoryId = this.items[index].subcategoryId;
|
||||
this.items.splice(index, 1);
|
||||
|
||||
// Check if subcategory still has items
|
||||
const remainingItems = this.items.filter(i => i.subcategoryId === subcategoryId);
|
||||
if (remainingItems.length === 0) {
|
||||
const subcategory = this.findSubcategoryById(subcategoryId);
|
||||
if (subcategory) {
|
||||
subcategory.hasItems = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return of(void 0).pipe(delay(300));
|
||||
}
|
||||
|
||||
|
||||
58
src/app/services/toast.service.ts
Normal file
58
src/app/services/toast.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ToastService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private readonly durations = {
|
||||
success: 2000,
|
||||
error: 4000,
|
||||
warning: 3000,
|
||||
info: 2000
|
||||
};
|
||||
|
||||
private readonly classes = {
|
||||
success: 'toast-success',
|
||||
error: 'toast-error',
|
||||
warning: 'toast-warning',
|
||||
info: 'toast-info'
|
||||
};
|
||||
|
||||
show(message: string, type: ToastType = 'info', duration?: number) {
|
||||
this.snackBar.open(
|
||||
message,
|
||||
'Close',
|
||||
{
|
||||
duration: duration || this.durations[type],
|
||||
horizontalPosition: 'end',
|
||||
verticalPosition: 'top',
|
||||
panelClass: [this.classes[type]]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
success(message: string, duration?: number) {
|
||||
this.show(message, 'success', duration);
|
||||
}
|
||||
|
||||
error(message: string, duration?: number) {
|
||||
this.show(message, 'error', duration);
|
||||
}
|
||||
|
||||
warning(message: string, duration?: number) {
|
||||
this.show(message, 'warning', duration);
|
||||
}
|
||||
|
||||
info(message: string, duration?: number) {
|
||||
this.show(message, 'info', duration);
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.snackBar.dismiss();
|
||||
}
|
||||
}
|
||||
183
src/app/services/validation.service.ts
Normal file
183
src/app/services/validation.service.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ValidationService {
|
||||
|
||||
validateRequired(value: any): string | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'This field is required';
|
||||
}
|
||||
if (typeof value === 'string' && value.trim().length === 0) {
|
||||
return 'This field cannot be empty';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
validateNumber(value: any, min?: number, max?: number): string | null {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
return 'Must be a valid number';
|
||||
}
|
||||
if (min !== undefined && num < min) {
|
||||
return `Must be at least ${min}`;
|
||||
}
|
||||
if (max !== undefined && num > max) {
|
||||
return `Must be at most ${max}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
validatePrice(value: any): string | null {
|
||||
const numberError = this.validateNumber(value, 0);
|
||||
if (numberError) return numberError;
|
||||
|
||||
const num = Number(value);
|
||||
if (num < 0) {
|
||||
return 'Price cannot be negative';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
validateQuantity(value: any): string | null {
|
||||
const numberError = this.validateNumber(value, 0);
|
||||
if (numberError) return numberError;
|
||||
|
||||
const num = Number(value);
|
||||
if (!Number.isInteger(num)) {
|
||||
return 'Quantity must be a whole number';
|
||||
}
|
||||
if (num < 0) {
|
||||
return 'Quantity cannot be negative';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
validateUrl(value: string): string | null {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return null; // Optional field
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Must be a valid URL (e.g., https://example.com)';
|
||||
}
|
||||
}
|
||||
|
||||
validateImageUrl(value: string): string | null {
|
||||
const urlError = this.validateUrl(value);
|
||||
if (urlError) return urlError;
|
||||
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
||||
const url = value.toLowerCase();
|
||||
const hasValidExtension = imageExtensions.some(ext => url.includes(ext));
|
||||
|
||||
if (!hasValidExtension) {
|
||||
return 'URL should point to an image file';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
validateId(value: string): string | null {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'ID is required';
|
||||
}
|
||||
|
||||
// ID should be URL-safe (no spaces, special chars)
|
||||
const validIdPattern = /^[a-z0-9_-]+$/i;
|
||||
if (!validIdPattern.test(value)) {
|
||||
return 'ID can only contain letters, numbers, hyphens, and underscores';
|
||||
}
|
||||
|
||||
if (value.length < 2) {
|
||||
return 'ID must be at least 2 characters';
|
||||
}
|
||||
|
||||
if (value.length > 50) {
|
||||
return 'ID must be less than 50 characters';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
validatePriority(value: any): string | null {
|
||||
return this.validateNumber(value, 0, 9999);
|
||||
}
|
||||
|
||||
validateCurrency(value: string): string | null {
|
||||
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||
if (!validCurrencies.includes(value)) {
|
||||
return `Currency must be one of: ${validCurrencies.join(', ')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
validateArrayNotEmpty(arr: any[], fieldName: string): string | null {
|
||||
if (!arr || arr.length === 0) {
|
||||
return `At least one ${fieldName} is required`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Composite validation for item
|
||||
validateItem(item: Partial<any>): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (item['name'] !== undefined) {
|
||||
const nameError = this.validateRequired(item['name']);
|
||||
if (nameError) errors['name'] = nameError;
|
||||
}
|
||||
|
||||
if (item['price'] !== undefined) {
|
||||
const priceError = this.validatePrice(item['price']);
|
||||
if (priceError) errors['price'] = priceError;
|
||||
}
|
||||
|
||||
if (item['quantity'] !== undefined) {
|
||||
const quantityError = this.validateQuantity(item['quantity']);
|
||||
if (quantityError) errors['quantity'] = quantityError;
|
||||
}
|
||||
|
||||
if (item['currency'] !== undefined) {
|
||||
const currencyError = this.validateCurrency(item['currency']);
|
||||
if (currencyError) errors['currency'] = currencyError;
|
||||
}
|
||||
|
||||
if (item['priority'] !== undefined) {
|
||||
const priorityError = this.validatePriority(item['priority']);
|
||||
if (priorityError) errors['priority'] = priorityError;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Composite validation for category/subcategory
|
||||
validateCategoryOrSubcategory(data: Partial<any>): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (data['name'] !== undefined) {
|
||||
const nameError = this.validateRequired(data['name']);
|
||||
if (nameError) errors['name'] = nameError;
|
||||
}
|
||||
|
||||
if (data['id'] !== undefined) {
|
||||
const idError = this.validateId(data['id']);
|
||||
if (idError) errors['id'] = idError;
|
||||
}
|
||||
|
||||
if (data['priority'] !== undefined) {
|
||||
const priorityError = this.validatePriority(data['priority']);
|
||||
if (priorityError) errors['priority'] = priorityError;
|
||||
}
|
||||
|
||||
if (data['img'] !== undefined && data['img']) {
|
||||
const imgError = this.validateImageUrl(data['img']);
|
||||
if (imgError) errors['img'] = imgError;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user