Refactor backend and frontend services, add unit tests, configure Ruff and Vitest, and delegate frontend state

This commit is contained in:
vee1e 2026-06-11 01:47:52 +05:30
parent a7cda4faf0
commit dbaba1c315
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
26 changed files with 4406 additions and 1954 deletions

2155
frontend/bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,177 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FormData } from '../../services/form.service';
@Component({
selector: 'app-form-list',
standalone: true,
imports: [
CommonModule,
DatePipe,
MatCardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatProgressSpinnerModule,
],
template: `
<div *ngIf="forms.length > 0" class="form-list">
<div class="form-list-header center-header">
<h3>Parsed Forms</h3>
</div>
<div class="delete-all-wrapper">
<button mat-raised-button color="warn" id="delete-all-forms-btn"
(click)="deleteAllForms.emit()"
[disabled]="isDeletingAll || forms.length === 0">
<mat-icon>delete_sweep</mat-icon>
{{isDeletingAll ? 'Deleting...' : 'Delete All Forms'}}
</button>
</div>
<div *ngFor="let form of forms" class="form-item"
[class.loading]="loadingFormId === form.id"
[class.previewed]="currentPreviewedFormId === form.id"
(click)="formClicked.emit(form)">
<div class="form-info">
<mat-icon *ngIf="loadingFormId !== form.id">description</mat-icon>
<mat-spinner *ngIf="loadingFormId === form.id" diameter="24" class="loading-spinner"></mat-spinner>
<div class="form-details">
<h4 class="form-title" [title]="form.title">{{form.title}}</h4>
<p>{{form.language || 'en'}} &bull; {{form.version || '1.0.0'}} &bull; {{(form.created_at ? (form.created_at | date:'short') : 'Unknown')}}</p>
</div>
</div>
<div class="form-actions">
<button mat-icon-button (click)="exportForm.emit(form); $event.stopPropagation()"
[disabled]="loadingFormId === form.id"
style="color: white;" matTooltip="Download JSON">
<mat-icon style="color: white;">download</mat-icon>
</button>
<button mat-icon-button color="accent" (click)="updateForm.emit(form); $event.stopPropagation()"
[disabled]="loadingFormId === form.id" matTooltip="Update Form">
<mat-icon>refresh</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteForm.emit(form); $event.stopPropagation()"
[disabled]="loadingFormId === form.id" matTooltip="Delete Form">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
</div>
`,
styles: [`
.form-list {
margin-top: 1rem;
}
.form-list-header {
padding: 0 1rem;
&.center-header {
text-align: center;
}
h3 {
color: white;
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
}
}
.delete-all-wrapper {
padding: 0.5rem 1rem 0.5rem 1rem;
display: flex;
justify-content: flex-end;
}
.form-item {
padding: 0.75rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
&.loading {
opacity: 0.7;
}
&.previewed {
background: rgba(255, 255, 255, 0.15);
border: 2px solid #ffffff;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.3);
transform: scale(1.009);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.form-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
mat-icon {
color: rgba(255, 255, 255, 0.7);
flex-shrink: 0;
}
.loading-spinner {
flex-shrink: 0;
}
}
.form-details {
min-width: 0;
flex: 1;
.form-title {
margin: 0 0 0.25rem 0;
color: white;
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
}
}
.form-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
}
`]
})
export class FormListComponent {
@Input() forms: FormData[] = [];
@Input() loadingFormId: string | null = null;
@Input() isDeletingAll = false;
@Input() currentPreviewedFormId: string | null = null;
@Output() formClicked = new EventEmitter<FormData>();
@Output() deleteForm = new EventEmitter<FormData>();
@Output() exportForm = new EventEmitter<FormData>();
@Output() updateForm = new EventEmitter<FormData>();
@Output() deleteAllForms = new EventEmitter<void>();
}

View file

@ -0,0 +1,420 @@
import { Component, Input, Output, EventEmitter, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-schema-modal',
standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule],
template: `
<div *ngIf="visible" class="schema-modal-overlay" (click)="closed.emit()">
<div class="schema-modal-content" (click)="$event.stopPropagation()">
<div class="schema-modal-header">
<h3>
<mat-icon class="schema-modal-icon">description</mat-icon>
Parsed Schema: {{fileName}}
</h3>
<button mat-icon-button (click)="closed.emit()" class="close-modal-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="schema-modal-body">
<div class="schema-section">
<h4>
<mat-icon class="section-icon">info</mat-icon>
Form Information
</h4>
<div class="schema-info-grid">
<div class="info-item">
<strong>Title:</strong>
<span>{{getFormTitle()}}</span>
</div>
<div class="info-item">
<strong>Language:</strong>
<span>{{getFormLanguage()}}</span>
</div>
<div class="info-item">
<strong>Version:</strong>
<span>{{getFormVersion()}}</span>
</div>
<div class="info-item">
<strong>Questions:</strong>
<span>{{getFormQuestionsCount()}}</span>
</div>
<div class="info-item">
<strong>Options:</strong>
<span>{{getFormOptionsCount()}}</span>
</div>
</div>
</div>
<div class="schema-section" *ngIf="getFormQuestions().length > 0">
<h4>
<mat-icon class="section-icon">quiz</mat-icon>
Questions Structure
</h4>
<div class="questions-container">
<div class="question-group">
<h5 class="group-title">Form Questions</h5>
<div *ngFor="let question of getFormQuestions()" class="question-item">
<div class="question-header">
<span class="question-name">#{{question.order}}</span>
<span class="question-type">Type {{question.input_type}}</span>
</div>
<div class="question-label">{{question.title}}</div>
<div *ngIf="question.answer_option && question.answer_option.length > 0" class="question-choices">
<div class="choice-header">Options:</div>
<div *ngFor="let option of question.answer_option" class="choice-item">
<span class="choice-name">{{option._id}}</span>
<span class="choice-label">{{option.name}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="schema-section">
<h4>
<mat-icon class="section-icon">code</mat-icon>
Raw JSON Schema
</h4>
<div class="json-container">
<pre class="json-content">{{formatJsonSchema()}}</pre>
</div>
</div>
</div>
<div class="schema-modal-actions">
<button mat-raised-button color="accent" (click)="copied.emit()">
<mat-icon>content_copy</mat-icon>
Copy JSON
</button>
<button mat-raised-button color="primary" (click)="downloaded.emit()">
<mat-icon>download</mat-icon>
Download JSON
</button>
<button mat-raised-button (click)="closed.emit()">
<mat-icon>close</mat-icon>
Close
</button>
</div>
</div>
</div>
`,
styles: [`
.schema-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.schema-modal-content {
background: rgba(20, 20, 35, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
width: 80vw;
max-width: 900px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.schema-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
h3 {
margin: 0;
color: #ffffff;
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.schema-modal-icon {
color: #ffffff;
}
.close-modal-btn {
color: rgba(255, 255, 255, 0.7);
}
}
.schema-modal-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.schema-section {
h4 {
margin: 0 0 1rem 0;
color: #ffffff;
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.section-icon {
color: #ffffff !important;
font-size: 18px;
}
}
.schema-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
gap: 0.5rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
strong {
color: #ffffff;
min-width: 80px;
}
}
.questions-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
}
.question-group {
margin-bottom: 1.5rem;
.group-title {
margin: 0 0 0.75rem 0;
color: #ffffff;
font-size: 0.9rem;
font-weight: 600;
}
}
.question-item {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
.question-name {
color: #ffffff;
font-weight: 600;
font-size: 0.85rem;
}
.question-type {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
}
.question-label {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.question-choices {
margin-top: 0.5rem;
.choice-header {
color: #ffffff;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.choice-item {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.8rem;
.choice-name {
color: #ffffff;
font-weight: 500;
min-width: 30px;
}
.choice-label {
color: rgba(255, 255, 255, 0.7);
}
}
}
}
.json-container {
max-height: 300px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: #0d1117;
}
.json-content {
margin: 0;
padding: 1rem;
color: #ffffff;
font-family: 'Inter', sans-serif;
font-size: 0.8rem;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
}
.schema-modal-actions {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
justify-content: flex-end;
}
@media (max-width: 768px) {
.schema-modal-content {
width: 95vw;
max-height: 95vh;
}
.schema-info-grid {
grid-template-columns: 1fr;
}
.schema-modal-actions {
flex-direction: column;
}
}
`]
})
export class SchemaModalComponent {
@Input() schema: any = null;
@Input() fileName = '';
@Input() visible = false;
@Output() closed = new EventEmitter<void>();
@Output() copied = new EventEmitter<void>();
@Output() downloaded = new EventEmitter<void>();
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.visible) {
this.closed.emit();
}
}
getFormTitle(): string {
if (!this.schema) return 'N/A';
if (Array.isArray(this.schema) && this.schema.length > 0) {
const formDef = this.schema[0];
if (formDef.language && Array.isArray(formDef.language) && formDef.language.length > 0) {
return formDef.language[0].title || 'N/A';
}
}
if (this.schema.title?.default) return this.schema.title.default;
return 'N/A';
}
getFormLanguage(): string {
if (!this.schema) return 'N/A';
if (Array.isArray(this.schema) && this.schema.length > 0) {
const formDef = this.schema[0];
if (typeof formDef.language === 'string') return formDef.language;
if (Array.isArray(formDef.language) && formDef.language.length > 0) {
return formDef.language[0].lng || 'N/A';
}
}
return this.schema.language || 'N/A';
}
getFormVersion(): string {
if (!this.schema) return 'N/A';
if (Array.isArray(this.schema) && this.schema.length > 0) {
return this.schema[0].version || 'N/A';
}
return this.schema.version || 'N/A';
}
getFormOptionsCount(): number {
if (!this.schema) return 0;
if (Array.isArray(this.schema) && this.schema.length > 0) {
const formDef = this.schema[0];
if (formDef.language && Array.isArray(formDef.language) && formDef.language.length > 0) {
const langConfig = formDef.language[0];
if (langConfig.question && Array.isArray(langConfig.question)) {
return langConfig.question.reduce((acc: number, q: any) => acc + (q.answer_option?.length ?? 0), 0);
}
}
}
return this.schema.metadata?.options_count ?? 0;
}
getFormQuestionsCount(): number {
if (!this.schema) return 0;
if (Array.isArray(this.schema) && this.schema.length > 0) {
const formDef = this.schema[0];
if (formDef.question && Array.isArray(formDef.question)) {
return formDef.question.length;
}
}
return this.schema.metadata?.questions_count ?? 0;
}
getFormQuestions(): any[] {
if (!this.schema) return [];
if (Array.isArray(this.schema) && this.schema.length > 0) {
const formDef = this.schema[0];
if (formDef.language && Array.isArray(formDef.language) && formDef.language.length > 0) {
return formDef.language[0].question ?? [];
}
}
return [];
}
formatJsonSchema(): string {
if (!this.schema) return '';
return JSON.stringify(this.schema, null, 2);
}
}

View file

@ -7,9 +7,14 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { FormService, FormData, FormDetails, OptionData, ParsedSchema } from '../../services/form.service';
import { FormValidation, ValidationError, ValidationWarning } from '../../models/form.model';
import { FormPreviewService } from '../../services/form-preview.service';
import { FormListComponent } from '../form-list/form-list.component';
import { SchemaModalComponent } from '../schema-modal/schema-modal.component';
import { UploadStateService } from '../../services/upload-state.service';
@Component({
selector: 'app-upload',
@ -22,7 +27,9 @@ import { FormPreviewService } from '../../services/form-preview.service';
MatProgressBarModule,
MatProgressSpinnerModule,
MatTooltipModule,
MatSnackBarModule
MatSnackBarModule,
FormListComponent,
SchemaModalComponent
],
template: `
<div *ngIf="isValidating || isUploading || isDeletingAll || isParsing" class="global-upload-progress"
@ -285,146 +292,30 @@ import { FormPreviewService } from '../../services/form-preview.service';
</div>
<!-- Form List -->
<div *ngIf="filteredForms.length > 0" class="form-list">
<div class="form-list-header center-header">
<h3>Parsed Forms</h3>
</div>
<div class="delete-all-wrapper">
<button mat-raised-button color="warn" (click)="confirmDeleteAllForms()" [disabled]="isDeletingAll || filteredForms.length === 0">
<mat-icon>delete_sweep</mat-icon>
{{isDeletingAll ? 'Deleting...' : 'Delete All Forms'}}
</button>
</div>
<div *ngFor="let form of filteredForms" class="form-item"
[class.loading]="loadingFormId === form.id"
[class.previewed]="currentPreviewedFormId === form.id"
(click)="showFormDetails(form)">
<div class="form-info">
<mat-icon *ngIf="loadingFormId !== form.id">description</mat-icon>
<mat-spinner *ngIf="loadingFormId === form.id" diameter="24" class="loading-spinner"></mat-spinner>
<div class="form-details">
<h4 class="form-title" [title]="form.title">{{form.title}}</h4>
<p>{{form.language || 'en'}} {{form.version || '1.0.0'}} {{(form.created_at ? (form.created_at | date:'short') : 'Unknown')}}</p>
</div>
</div>
<div class="form-actions">
<button mat-icon-button (click)="exportFormAsJson(form, $event)" [disabled]="loadingFormId === form.id" style="color: white;" matTooltip="Download JSON">
<mat-icon style="color: white;">download</mat-icon>
</button>
<button mat-icon-button color="accent" (click)="onUpdateButtonClick(form, $event)" [disabled]="loadingFormId === form.id" matTooltip="Update Form">
<mat-icon>refresh</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteForm(form, $event)" [disabled]="loadingFormId === form.id" matTooltip="Delete Form">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
</div>
<app-form-list
[forms]="filteredForms"
[loadingFormId]="loadingFormId"
[isDeletingAll]="isDeletingAll"
[currentPreviewedFormId]="currentPreviewedFormId"
(formClicked)="showFormDetails($event)"
(deleteForm)="deleteForm($event)"
(exportForm)="exportFormAsJson($event)"
(updateForm)="onUpdateButtonClick($event)"
(deleteAllForms)="confirmDeleteAllForms()">
</app-form-list>
</mat-card-content>
</mat-card>
<input type="file" accept=".xls,.xlsx" #updateFileInput id="global-update-file-input" style="display: none" (change)="onUpdateFileSelected($event)">
<!-- Schema Modal -->
<div *ngIf="showSchemaModal" class="schema-modal-overlay" (click)="closeSchemaModal()">
<div class="schema-modal-content" (click)="$event.stopPropagation()">
<div class="schema-modal-header">
<h3>
<mat-icon class="schema-modal-icon">description</mat-icon>
Parsed Schema: {{selectedSchemaFileName}}
</h3>
<button mat-icon-button (click)="closeSchemaModal()" class="close-modal-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="schema-modal-body">
<div class="schema-section">
<h4>
<mat-icon class="section-icon">info</mat-icon>
Form Information
</h4>
<div class="schema-info-grid">
<div class="info-item">
<strong>Title:</strong>
<span>{{getFormTitle(selectedSchema)}}</span>
</div>
<div class="info-item">
<strong>Language:</strong>
<span>{{getFormLanguage(selectedSchema)}}</span>
</div>
<div class="info-item">
<strong>Version:</strong>
<span>{{getFormVersion(selectedSchema)}}</span>
</div>
<div class="info-item">
<strong>Questions:</strong>
<span>{{getFormQuestionsCount(selectedSchema)}}</span>
</div>
<div class="info-item">
<strong>Options:</strong>
<span>{{getFormOptionsCount(selectedSchema)}}</span>
</div>
<div class="info-item">
<strong>Form ID:</strong>
<span>{{getFormId(selectedSchema)}}</span>
</div>
</div>
</div>
<div class="schema-section" *ngIf="getFormQuestions(selectedSchema).length > 0">
<h4>
<mat-icon class="section-icon">quiz</mat-icon>
Questions Structure
</h4>
<div class="questions-container">
<div class="question-group">
<h5 class="group-title">Form Questions</h5>
<div *ngFor="let question of getFormQuestions(selectedSchema)" class="question-item">
<div class="question-header">
<span class="question-name">#{{question.order}}</span>
<span class="question-type">Type {{question.input_type}}</span>
</div>
<div class="question-label">{{question.title}}</div>
<div *ngIf="question.answer_option && question.answer_option.length > 0" class="question-choices">
<div class="choice-header">Options:</div>
<div *ngFor="let option of question.answer_option" class="choice-item">
<span class="choice-name">{{option._id}}</span>
<span class="choice-label">{{option.name}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="schema-section">
<h4>
<mat-icon class="section-icon">code</mat-icon>
Raw JSON Schema
</h4>
<div class="json-container">
<pre class="json-content">{{formatJsonSchema(selectedSchema)}}</pre>
</div>
</div>
</div>
<div class="schema-modal-actions">
<button mat-raised-button color="accent" (click)="copySchemaToClipboard()">
<mat-icon>content_copy</mat-icon>
Copy JSON
</button>
<button mat-raised-button color="primary" (click)="downloadSchemaFromModal()">
<mat-icon>download</mat-icon>
Download JSON
</button>
<button mat-raised-button (click)="closeSchemaModal()">
<mat-icon>close</mat-icon>
Close
</button>
</div>
</div>
</div>
<app-schema-modal
[schema]="selectedSchema"
[fileName]="selectedSchemaFileName"
[visible]="showSchemaModal"
(closed)="closeSchemaModal()"
(copied)="copySchemaToClipboard()"
(downloaded)="downloadSchemaFromModal()">
</app-schema-modal>
`,
styles: [`
.upload-card {
@ -1580,7 +1471,7 @@ export class UploadComponent implements OnInit, OnChanges {
currentUploadIndex = 0;
// Async upload control
private uploadController: AbortController | null = null;
private cancelSubject$ = new Subject<void>();
private activeUploads = new Set<Promise<any>>();
private maxConcurrentUploads = 3;
@ -1594,6 +1485,7 @@ export class UploadComponent implements OnInit, OnChanges {
updateTargetForm: FormData | null = null;
private platformId = inject(PLATFORM_ID);
private uploadStateService = inject(UploadStateService);
constructor(private formService: FormService, private formPreviewService: FormPreviewService, private snackBar: MatSnackBar, private cdr: ChangeDetectorRef) {}
@ -1736,54 +1628,14 @@ export class UploadComponent implements OnInit, OnChanges {
this.currentUploadIndex = 0;
this.uploadProgress = { current: 0, total: this.selectedFiles.length };
// Create new abort controller for this upload session
this.uploadController = new AbortController();
// Reset cancellation subject for this upload session
this.cancelSubject$ = new Subject<void>();
this.activeUploads.clear();
this.saveUploadState();
await this.processQueueAsync();
}
private processNextFile() {
if (this.uploadPaused || this.currentUploadIndex >= this.uploadQueue.length) {
if (this.currentUploadIndex >= this.uploadQueue.length) {
this.completeUpload();
}
this.saveUploadState();
return;
}
const file = this.uploadQueue[this.currentUploadIndex];
this.formService.uploadSingleFile(file).subscribe({
next: (result: any) => {
this.processedFiles.push(file.name);
this.currentUploadIndex++;
this.uploadProgress.current = this.currentUploadIndex;
this.saveUploadState();
if (!result.error) {
this.snackBar.open(`Successfully processed ${file.name}`, 'Close', {
duration: 2000,
verticalPosition: 'top'
});
} else {
this.snackBar.open(`Failed to process ${file.name}`, 'Close', { duration: 3000 });
}
setTimeout(() => this.processNextFile(), 500);
},
error: (error: any) => {
console.error(`Upload failed for ${file.name}:`, error);
this.snackBar.open(`Upload failed for ${file.name}`, 'Close', { duration: 3000 });
this.currentUploadIndex++;
this.uploadProgress.current = this.currentUploadIndex;
this.saveUploadState();
setTimeout(() => this.processNextFile(), 500);
}
});
}
private async processQueueAsync() {
try {
const remainingFiles = this.uploadQueue.slice(this.currentUploadIndex);
@ -1842,41 +1694,38 @@ export class UploadComponent implements OnInit, OnChanges {
}
private async uploadSingleFileAsync(file: File, index: number): Promise<void> {
return new Promise((resolve, reject) => {
if (this.uploadController?.signal.aborted) {
reject(new Error('Upload cancelled'));
return;
}
this.formService.uploadSingleFile(file).subscribe({
return new Promise<void>((resolve) => {
let handled = false;
this.formService.uploadSingleFile(file).pipe(takeUntil(this.cancelSubject$)).subscribe({
next: (result: any) => {
handled = true;
if (!this.uploadStopped) {
this.processedFiles.push(file.name);
this.uploadProgress.current = this.processedFiles.length;
if (!result.error) {
this.snackBar.open(`Successfully processed ${file.name}`, 'Close', {
duration: 2000,
verticalPosition: 'top'
this.snackBar.open(`Successfully processed ${file.name}`, 'Close', {
duration: 2000,
verticalPosition: 'top'
});
} else {
this.snackBar.open(`Failed to process ${file.name}`, 'Close', { duration: 3000 });
}
this.saveUploadState();
}
resolve();
},
error: (error: any) => {
console.error(`Failed to upload ${file.name}:`, error);
if (!this.uploadStopped) {
this.snackBar.open(`Failed to upload ${file.name}`, 'Close', {
this.snackBar.open(`Failed to upload ${file.name}`, 'Close', {
duration: 3000,
panelClass: ['error-snackbar']
});
}
resolve(); // Resolve even on error to continue with other files
}
resolve();
},
complete: () => resolve() // handles both normal completion and takeUntil cancellation
});
});
}
@ -1927,17 +1776,15 @@ export class UploadComponent implements OnInit, OnChanges {
async cancelUpload() {
if (confirm('Are you sure you want to cancel the upload? Progress will be lost.')) {
this.uploadStopped = true;
// Abort any ongoing HTTP requests
if (this.uploadController) {
this.uploadController.abort();
}
// Cancel all in-flight HTTP requests via RxJS takeUntil
this.cancelSubject$.next();
// Wait for active uploads to complete/cancel
if (this.activeUploads.size > 0) {
await Promise.allSettled([...this.activeUploads]);
}
this.resetUploadState();
this.cdr.detectChanges();
this.snackBar.open('Upload cancelled', 'Close', { duration: 3000 });
@ -1956,84 +1803,42 @@ export class UploadComponent implements OnInit, OnChanges {
this.allValid = false;
this.uploadProgress = { current: 0, total: 0 };
// Clean up async resources
if (this.uploadController) {
this.uploadController.abort();
this.uploadController = null;
}
// Cancel any in-flight requests and reset the subject for the next session
this.cancelSubject$.next();
this.cancelSubject$ = new Subject<void>();
this.activeUploads.clear();
this.clearUploadSession();
}
private saveUploadState() {
if (typeof window === 'undefined' || !window.sessionStorage) {
return;
}
if (this.isUploading || this.uploadPaused) {
const state = {
isUploading: this.isUploading,
uploadPaused: this.uploadPaused,
uploadStopped: this.uploadStopped,
uploadQueue: this.uploadQueue.map(file => ({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
})),
processedFiles: this.processedFiles,
currentUploadIndex: this.currentUploadIndex,
uploadProgress: this.uploadProgress,
validationResults: this.validationResults,
allValid: this.allValid,
timestamp: Date.now()
};
sessionStorage.setItem('uploadState', JSON.stringify(state));
}
this.uploadStateService.saveUploadState({
isUploading: this.isUploading,
uploadPaused: this.uploadPaused,
uploadStopped: this.uploadStopped,
uploadQueue: this.uploadQueue.map(file => ({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
})),
processedFiles: this.processedFiles,
currentUploadIndex: this.currentUploadIndex,
uploadProgress: this.uploadProgress,
validationResults: this.validationResults,
allValid: this.allValid
});
}
private restoreUploadState() {
if (typeof window === 'undefined' || !window.sessionStorage) {
return;
}
const savedState = sessionStorage.getItem('uploadState');
if (savedState) {
try {
const state = JSON.parse(savedState);
const timeDiff = Date.now() - state.timestamp;
if (timeDiff < 30 * 60 * 1000) {
this.isUploading = state.isUploading;
this.uploadPaused = state.uploadPaused;
this.processedFiles = state.processedFiles || [];
this.currentUploadIndex = state.currentUploadIndex || 0;
this.uploadProgress = state.uploadProgress || { current: 0, total: 0 };
this.validationResults = state.validationResults || {};
this.allValid = state.allValid || false;
if (this.isUploading || this.uploadPaused) {
this.snackBar.open('📋 Previous upload session restored. You can resume or cancel.', 'Close', {
duration: 5000,
panelClass: ['custom-snackbar']
});
}
} else {
this.clearUploadSession();
}
} catch (error) {
console.error('Failed to restore upload state:', error);
this.clearUploadSession();
}
const message = this.uploadStateService.checkInterruptedUpload();
if (message) {
this.snackBar.open(message, 'Close', { duration: 6000 });
}
}
private clearUploadSession() {
if (typeof window === 'undefined' || !window.sessionStorage) {
return;
}
sessionStorage.removeItem('uploadState');
this.uploadStateService.clearUploadSession();
}
getFileUploadStatus(fileName: string, index: number): string {
@ -2076,8 +1881,8 @@ export class UploadComponent implements OnInit, OnChanges {
}
}
deleteForm(form: FormData, event: Event): void {
event.stopPropagation();
deleteForm(form: FormData, event?: Event): void {
if (event) event.stopPropagation();
if (confirm(`Are you sure you want to delete "${form.title}"?`)) {
this.formService.deleteForm(form.id).subscribe({
next: () => {
@ -2090,8 +1895,8 @@ export class UploadComponent implements OnInit, OnChanges {
}
}
exportFormAsJson(form: FormData, event: Event): void {
event.stopPropagation();
exportFormAsJson(form: FormData, event?: Event): void {
if (event) event.stopPropagation();
this.formService.getFormById(form.id).subscribe({
next: (details) => {
const json = JSON.stringify(details, null, 2);
@ -2129,31 +1934,21 @@ export class UploadComponent implements OnInit, OnChanges {
});
}
async deleteAllForms() {
deleteAllForms() {
if (this.isDeletingAll || this.parsedForms.length === 0) return;
this.isDeletingAll = true;
this.deleteProgress = { current: 0, total: this.parsedForms.length };
let deleted = 0;
const deleteNext = (index: number) => {
if (index >= this.parsedForms.length) {
this.formService.deleteAllForms().subscribe({
next: () => {
this.deleteProgress.current = this.parsedForms.length;
this.isDeletingAll = false;
this.loadForms();
return;
},
error: () => {
this.isDeletingAll = false;
this.snackBar.open('Failed to delete all forms', 'Close', { duration: 3000 });
}
this.formService.deleteForm(this.parsedForms[index].id).subscribe({
next: () => {
deleted++;
this.deleteProgress.current = deleted;
deleteNext(index + 1);
},
error: () => {
deleted++;
this.deleteProgress.current = deleted;
deleteNext(index + 1);
}
});
};
deleteNext(0);
});
}
confirmDeleteAllForms() {
@ -2195,8 +1990,8 @@ export class UploadComponent implements OnInit, OnChanges {
return labels[type] || type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
}
onUpdateButtonClick(form: FormData, event: Event): void {
event.stopPropagation();
onUpdateButtonClick(form: FormData, event?: Event): void {
if (event) event.stopPropagation();
this.updateTargetForm = form;
const input = document.getElementById('global-update-file-input') as HTMLInputElement;
if (input) {

View file

@ -81,8 +81,10 @@ export class FormService {
uploadFile(file: File): Observable<FormDetails> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<FormDetails>(`${this.apiUrl}/upload`, formData);
formData.append('files', file);
return this.http.post<any[]>(`${this.apiUrl}/upload`, formData).pipe(
map((results: any[]) => results[0])
);
}
uploadSingleFile(file: File): Observable<any> {

View file

@ -0,0 +1,65 @@
import { Injectable } from '@angular/core';
const SESSION_KEY = 'uploadState';
const INTERRUPTED_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
@Injectable({ providedIn: 'root' })
export class UploadStateService {
/**
* Persist the current upload state to sessionStorage.
* Only writes when an upload is in-progress or paused.
*/
saveUploadState(state: {
isUploading: boolean;
uploadPaused: boolean;
uploadStopped: boolean;
uploadQueue: { name: string; size: number; type: string; lastModified: number }[];
processedFiles: string[];
currentUploadIndex: number;
uploadProgress: { current: number; total: number };
validationResults: Record<string, any>;
allValid: boolean;
}): void {
if (typeof window === 'undefined' || !window.sessionStorage) {
return;
}
if (state.isUploading || state.uploadPaused) {
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ ...state, timestamp: Date.now() }));
}
}
/** Remove the upload session key. */
clearUploadSession(): void {
if (typeof window === 'undefined' || !window.sessionStorage) {
return;
}
sessionStorage.removeItem(SESSION_KEY);
}
/**
* Check whether a recent interrupted upload exists.
* Always clears the session so the user starts fresh.
* @returns A user-facing message string if interrupted, or null.
*/
checkInterruptedUpload(): string | null {
if (typeof window === 'undefined' || !window.sessionStorage) {
return null;
}
const savedState = sessionStorage.getItem(SESSION_KEY);
if (!savedState) {
return null;
}
// Always clear — File objects cannot survive a page reload.
this.clearUploadSession();
try {
const state = JSON.parse(savedState);
const timeDiff = Date.now() - state.timestamp;
if (timeDiff < INTERRUPTED_THRESHOLD_MS && (state.isUploading || state.uploadPaused)) {
return 'Previous upload was interrupted. Please re-select your files to try again.';
}
} catch {
// ignore malformed session data
}
return null;
}
}

View file

@ -0,0 +1 @@
{"API_URL":"http://localhost:8000/api"}

View file

@ -7,6 +7,14 @@ const __dirname = dirname(__filename)
const repoRoot = resolve(__dirname, '..')
export default defineConfig({
resolve: {
alias: {
'@angular/core': resolve(__dirname, 'node_modules/@angular/core'),
'@angular/common': resolve(__dirname, 'node_modules/@angular/common'),
'@angular/compiler': resolve(__dirname, 'node_modules/@angular/compiler'),
'rxjs': resolve(__dirname, 'node_modules/rxjs'),
}
},
server: {
fs: {
allow: ['..']