diff --git a/docs/components-api/fileupload.md b/docs/components-api/fileupload.md index 94af8d28..29484a8f 100644 --- a/docs/components-api/fileupload.md +++ b/docs/components-api/fileupload.md @@ -2,6 +2,8 @@ # FileUpload +> ✅ **Реализован**: `ExtraFileUploadComponent` (`@cdek-it/angular-ui-kit`) соответствует спецификации. + ## Свойства | Свойство | Описание | Типизация | @@ -16,6 +18,7 @@ | `url` | endpoint загрузки (если компонент сам выполняет запрос) | `string` | | `labels` | статические тексты интерфейса (сгруппированы) | `Labels` | | `messages` | шаблоны сообщений об ошибках (сгруппированы, заменяют 6 полей) | `Messages` | +| `uploadError` | текст ошибки реальной отправки; компонент всегда работает в customUpload, поэтому решение "запрос не удался" остаётся за приложением — оно выставляет этот проп после своего HTTP-вызова, а компонент рисует сообщение на штатном месте (сразу после зоны дропа, перед списком файлов, как и сообщение об успехе) | `string \| null` | ## События diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index 750aefd9..59973ec7 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, ChangeDetectionStrategy, inject, forwardRef } from '@angular/core'; +import { Component, Input, Output, EventEmitter, ViewChild, ChangeDetectorRef, ChangeDetectionStrategy, inject, forwardRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FileUpload, @@ -7,23 +7,93 @@ import { FileUploadErrorEvent, FileUploadHandlerEvent, } from 'primeng/fileupload'; -import { ProgressBar } from 'primeng/progressbar'; -import { Message } from 'primeng/message'; import { PrimeTemplate } from 'primeng/api'; import { ExtraButtonComponent } from '@cdek-it/angular-ui-kit/components/button'; +import { ExtraBadgeComponent, ExtraBadgeSeverity } from '@cdek-it/angular-ui-kit/components/badge'; +import { ExtraMessageComponent } from '@cdek-it/angular-ui-kit/components/message'; +import { ExtraProgressBarComponent } from '@cdek-it/angular-ui-kit/components/progressbar'; -// PrimeNG добавляет objectURL для превью в рантайме, но не типизирует его +// PrimeNG добавляет objectURL для превью изображений в рантайме, но не типизирует его type PreviewFile = File & { objectURL?: string }; +export type ExtraFileUploadMode = 'auto' | 'manual' | 'basic'; +export type ExtraFileUploadStatus = 'pending' | 'uploading' | 'success' | 'error'; + +export interface ExtraFileUploadFile { + file: File; + status: ExtraFileUploadStatus; + progress?: number; + errorMessage?: string; +} + +export interface ExtraFileUploadLabels { + dropzoneTitle?: string; + dropzoneCaption?: string; + chooseButton?: string; + uploadButton?: string; + cancelButton?: string; +} + +export interface ExtraFileUploadMessageTemplate { + summary: string; + detail: string; +} + +export interface ExtraFileUploadMessages { + invalidFileSize?: ExtraFileUploadMessageTemplate; + invalidFileType?: ExtraFileUploadMessageTemplate; + invalidFileLimit?: ExtraFileUploadMessageTemplate; +} + +// Формы событий совпадают 1:1 с PrimeNG — переиспользуем их под своими именами, +// чтобы публичное API не заставляло консьюмера импортировать что-то из primeng/fileupload. +export type ExtraFileUploadSelectEvent = FileSelectEvent; +export type ExtraFileUploadRemoveEvent = FileRemoveEvent; +export type ExtraFileUploadUploadEvent = FileUploadHandlerEvent; +export type ExtraFileUploadErrorEvent = FileUploadErrorEvent; + +const DEFAULT_LABELS: Required = { + dropzoneTitle: 'Чтобы загрузить файлы кликните или перетащите их в эту область', + dropzoneCaption: 'Можно загрузить не более 10 файлов размером 1 MB', + chooseButton: 'Выбрать файл', + uploadButton: 'Отправить', + cancelButton: 'Очистить', +}; + +const DEFAULT_MESSAGES: Required = { + invalidFileSize: { summary: '{0}: Некорректный размер файла', detail: 'Максимальный размер — {0}' }, + invalidFileType: { summary: '{0}: Некорректный тип файла', detail: 'Допустимые типы: {0}' }, + invalidFileLimit: { summary: 'Превышен лимит файлов', detail: 'Максимум: {0}' }, +}; + +const STATUS_LABEL: Record = { + pending: 'Ожидает', + uploading: 'Ожидает', + success: 'Загружено', + error: 'Ошибка', +}; + +const STATUS_SEVERITY: Record = { + pending: 'info', + uploading: 'info', + success: 'success', + error: 'danger', +}; + +/** Имитация прогресса пачки (демо-режим без реального backend, см. `upload`/customUpload). */ +const UPLOAD_TICK_MS = 150; +const UPLOAD_TICK_STEP = 10; + @Component({ selector: 'extra-fileupload', standalone: true, - imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent], + imports: [FileUpload, PrimeTemplate, ExtraButtonComponent, ExtraBadgeComponent, ExtraMessageComponent, ExtraProgressBarComponent], host: { style: 'display: contents' }, providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ExtraFileUploadComponent), multi: true }], template: ` - -
-
- -
- {{ dropzoneTitle }} - - - {{ dropzoneCaption }} - + + @if (mode === 'basic') { +
+
+
-
+ } @else if (!dragAndDrop) { +
+ +
+ } - -
- @if (isUploading) { - - } - @if (uploadSuccess) { - - Файлы успешно загружены - - } - @if (selectedFiles.length > 0) { -
- @for (file of selectedFiles; track file.name + file.size; let i = $index) { -
-
- @if (isImage(file)) { - - } @else { - - } -
- {{ file.name }} - - - {{ formatSize(file.size) }} - -
-
- + + @if (mode !== 'basic') { +
+ @if (dragAndDrop) { +
+ +
+ {{ resolvedLabels.dropzoneTitle }} + + + {{ resolvedLabels.dropzoneCaption }} +
- } -
- } - @if (uploadedFiles.length > 0) { -
- @for (file of uploadedFiles; track file.name + file.size; let i = $index) { -
-
- -
- {{ file.name }} - Загружено +
+ } + + @if (isUploading) { + + } + + @for (msg of messages; track $index) { + + } + + @if (uploadSuccess) { + + } + + @if (uploadError) { + + } + + @if (files.length > 0) { +
+ @for (entry of files; track entry.file; let i = $index) { +
+
+ @if (isImage(entry.file)) { + + } @else { + + } +
+ {{ entry.file.name }} + + + {{ formatSize(entry.file.size) }} + + +
+
- -
- } -
- } - @if (selectedFiles.length > 0 || uploadedFiles.length > 0) { - - } -
+ } +
+ } + + @if (files.length > 0) { + + } +
+ }
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ExtraFileUploadComponent implements ControlValueAccessor { - private el = inject(ElementRef); private cdr = inject(ChangeDetectorRef); @ViewChild('fuRef') fuRef!: FileUpload; @@ -140,37 +214,52 @@ export class ExtraFileUploadComponent implements ControlValueAccessor { @Input() accept = 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'; @Input() maxFileSize = 1000000; @Input() fileLimit: number | undefined = undefined; + @Input() dragAndDrop = true; + @Input() mode: ExtraFileUploadMode = 'manual'; @Input() disabled = false; - @Input() dropzoneTitle = 'Чтобы загрузить файлы кликните или перетащите их в эту область'; - @Input() dropzoneCaption = 'Можно загрузить не более 10 файлов размером 1 MB'; - - @Input() invalidFileSizeMessageSummary = '{0}: Некорректный размер файла'; - @Input() invalidFileSizeMessageDetail = 'Максимальный размер — {0}'; - @Input() invalidFileTypeMessageSummary = '{0}: Некорректный тип файла'; - @Input() invalidFileTypeMessageDetail = 'Допустимые типы: {0}'; - @Input() invalidFileLimitMessageSummary = 'Превышен лимит файлов'; - @Input() invalidFileLimitMessageDetail = 'Максимум: {0}'; - - @Output() onSelectEvent = new EventEmitter(); - @Output() onRemoveEvent = new EventEmitter(); - @Output() onClearEvent = new EventEmitter(); - @Output() onError = new EventEmitter(); - @Output() onUpload = new EventEmitter(); - - selectedFiles: PreviewFile[] = []; - uploadedFiles: PreviewFile[] = []; - totalSize = 0; + @Input() labels: ExtraFileUploadLabels = {}; + @Input() messages: ExtraFileUploadMessages = {}; + /** + * Текст ошибки реальной отправки (XHR ≥400 / сеть). Компонент всегда работает в customUpload — + * сам запрос и решение об ошибке остаются за приложением, оно и выставляет этот проп после + * своего вызова; компонент только рисует сообщение в нужном месте разметки (после зоны дропа, + * перед списком файлов — как success-сообщение). Сбрасывается приложением же (например, перед + * повторной отправкой). + */ + @Input() uploadError: string | null = null; + + @Output() onSelect = new EventEmitter(); + @Output() onRemove = new EventEmitter(); + @Output() onClear = new EventEmitter(); + @Output() onUpload = new EventEmitter(); + @Output() onError = new EventEmitter(); + + files: ExtraFileUploadFile[] = []; totalSizePercent = 0; uploadSuccess = false; isUploading = false; - private uploadCbRef: (() => void) | null = null; - private clearCbRef: (() => void) | null = null; private onChange: (files: File[]) => void = () => {}; private onTouched: () => void = () => {}; - writeValue(files: File[]): void { - this.selectedFiles = files ?? []; + get resolvedLabels(): Required { + return { ...DEFAULT_LABELS, ...this.labels }; + } + + get resolvedMessages(): Required { + return { + invalidFileSize: { ...DEFAULT_MESSAGES.invalidFileSize, ...this.messages.invalidFileSize }, + invalidFileType: { ...DEFAULT_MESSAGES.invalidFileType, ...this.messages.invalidFileType }, + invalidFileLimit: { ...DEFAULT_MESSAGES.invalidFileLimit, ...this.messages.invalidFileLimit }, + }; + } + + get hasPending(): boolean { + return this.files.some(f => f.status === 'pending'); + } + + writeValue(files: File[] | null): void { + this.files = (files ?? []).map(file => ({ file, status: 'pending' as const })); this.cdr.markForCheck(); } @@ -187,52 +276,22 @@ export class ExtraFileUploadComponent implements ControlValueAccessor { this.cdr.markForCheck(); } - get uploadCb(): (() => void) | null { - return this.uploadCbRef; - } - - storeCallbacks(upload: () => void, clear: () => void): string { - this.uploadCbRef = upload; - this.clearCbRef = clear; - return ''; + statusLabel(status: ExtraFileUploadStatus): string { + return STATUS_LABEL[status]; } - onDrop(event: DragEvent): void { - event.preventDefault(); - const files = event.dataTransfer?.files; - if (!files?.length || this.disabled) return; - const accepted = this.filterFilesByAccept(Array.from(files)); - if (!accepted.length) return; - const dt = new DataTransfer(); - accepted.forEach(f => dt.items.add(f)); - this.fuRef.onFileSelect({ target: { files: dt.files } } as unknown as Event); - } - - private filterFilesByAccept(files: File[]): File[] { - if (!this.accept) return files; - const types = this.accept.split(',').map(t => t.trim()); - return files.filter(file => - types.some(type => { - if (type.includes('*')) { - return file.type.startsWith(type.replace('*', '')); - } - if (type.startsWith('.')) { - return file.name.toLowerCase().endsWith(type.toLowerCase()); - } - return file.type === type; - }), - ); - } - - onChooseClick(): void { - const input = this.el.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; - input?.click(); + statusSeverity(status: ExtraFileUploadStatus): ExtraBadgeSeverity { + return STATUS_SEVERITY[status]; } isImage(file: File): boolean { return file.type.startsWith('image/'); } + previewUrl(file: File): string | undefined { + return (file as PreviewFile).objectURL; + } + formatSize(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; @@ -242,62 +301,71 @@ export class ExtraFileUploadComponent implements ControlValueAccessor { } onSelectedFiles(event: FileSelectEvent): void { - this.selectedFiles = [...(this.fuRef?.files || [])]; - this.totalSize = this.selectedFiles.reduce((acc, f) => acc + f.size, 0); + const current = this.fuRef?.files ?? []; + const known = new Set(this.files.map(f => f.file)); + const added = current.filter(f => !known.has(f)).map(file => ({ file, status: 'pending' as const })); + this.files = [...this.files, ...added]; this.uploadSuccess = false; - this.isUploading = this.selectedFiles.length > 0; + this.onChange(this.pendingNativeFiles()); + this.onTouched(); + this.cdr.markForCheck(); + this.onSelect.emit(event); + } + + onUploader(event: FileUploadHandlerEvent): void { + const pending = this.files.filter(f => f.status === 'pending'); + pending.forEach(f => (f.status = 'uploading')); + this.isUploading = true; this.totalSizePercent = 0; + this.cdr.markForCheck(); let progress = 0; const interval = setInterval(() => { - progress += 10; + progress += UPLOAD_TICK_STEP; this.totalSizePercent = Math.min(progress, 100); - if (progress >= 100) clearInterval(interval); + if (progress >= 100) { + clearInterval(interval); + pending.forEach(f => (f.status = 'success')); + this.isUploading = false; + this.uploadSuccess = true; + // Сбрасываем внутреннюю очередь PrimeNG напрямую через сеттер files, а не через + // clearCallback()/clear() — тот безусловно эмитит onClear, а это не пользовательская очистка. + this.fuRef.files = []; + this.onChange(this.pendingNativeFiles()); + } this.cdr.markForCheck(); - }, 40); - - this.onChange(this.selectedFiles); - this.onTouched(); - this.cdr.markForCheck(); - this.onSelectEvent.emit(event); - } + }, UPLOAD_TICK_MS); - onUploader(event: FileUploadHandlerEvent): void { - setTimeout(() => { - this.clearCbRef?.(); - this.selectedFiles = []; - this.uploadedFiles = [...(event.files || [])]; - this.totalSize = 0; - this.totalSizePercent = 0; - this.uploadSuccess = true; - this.isUploading = false; - this.onChange([]); - this.cdr.markForCheck(); - }, 1500); this.onUpload.emit(event); } - onRemoveFile(file: File, removeFileCallback: (index: number) => void, index: number): void { - removeFileCallback(index); - this.selectedFiles = [...(this.fuRef?.files || [])]; - this.totalSize -= file.size; - this.totalSizePercent = Math.min((this.totalSize / (this.maxFileSize || 1000000)) * 100, 100); - if (this.totalSize <= 0) { - this.isUploading = false; + onRemoveFile( + entry: ExtraFileUploadFile, + originalEvent: Event, + removeFileCallback: (event: Event, index: number) => void, + ): void { + if (entry.status === 'pending' || entry.status === 'uploading') { + const primeIndex = this.fuRef.files.indexOf(entry.file); + removeFileCallback(originalEvent, primeIndex); // сам эмитит onRemove через (onRemove) на + } else { + this.onRemove.emit({ file: entry.file, originalEvent }); } - this.onChange(this.selectedFiles); + this.files = this.files.filter(f => f !== entry); + this.onChange(this.pendingNativeFiles()); this.cdr.markForCheck(); } - onClearUpload(): void { - this.clearCbRef?.(); - this.selectedFiles = []; - this.uploadedFiles = []; - this.totalSize = 0; - this.totalSizePercent = 0; + onClearAll(): void { + this.fuRef.clear(); // пользовательская очистка — эмитит onClear легитимно + this.files = []; this.uploadSuccess = false; this.isUploading = false; + this.totalSizePercent = 0; this.onChange([]); this.cdr.markForCheck(); } + + private pendingNativeFiles(): File[] { + return this.files.filter(f => f.status === 'pending' || f.status === 'uploading').map(f => f.file); + } } diff --git a/src/lib/components/fileupload/fileupload.figma.md b/src/lib/components/fileupload/fileupload.figma.md new file mode 100644 index 00000000..ee6de038 --- /dev/null +++ b/src/lib/components/fileupload/fileupload.figma.md @@ -0,0 +1,89 @@ +--- +component: ExtraFileUpload +selector: extra-fileupload +import: + symbol: ExtraFileUploadComponent + from: '@cdek-it/angular-ui-kit' +figma: + fileKey: 'Q1BWgZ7zoV5UzlBOnjW0cM' + nodeId: '24068:112138' + name: ' API' +status: stable +updated: '2026-09-29' +--- + +## Overview + +`ExtraFileUpload` — загрузчик файлов с drag-and-drop, очередью, прогресс-баром на пачку и +предпросмотром. Оборачивает PrimeNG `p-fileupload` в режиме `advanced`+`customUpload`: реальный +HTTP-запрос компонент не делает, отправку выполняет вызывающее приложение через событие `upload` +(`uploadHandler` PrimeNG). Композирует DS-компоненты `ExtraBadge` (статус строки файла), +`ExtraMessage` (ошибки валидации и сообщение об успехе) и `ExtraProgressBar` (общий прогресс +пачки) — их собственный API не выносится на `ExtraFileUpload`, см. `docs/components-api/fileupload.md`. + +Соответствует Figma-компоненту ``; полная API-таблица — на фрейме ` API` +(nodeId `24068:112138`), живые примеры состояний — на фрейме «FileUpload — живые примеры» +(nodeId `24805:11571`). + +`componentKey` не указан: аккаунт, на котором работал агент, не имеет прав Dev/Full seat на +Organization/Enterprise-плане, необходимых для Code Connect API Figma — значение недоступно для +выгрузки, а не забыто. + +## Props mapping + +| Свойство | Тип | По умолчанию | Описание | +|---------------|-----------------------------------|--------------|----------| +| `mode` | `'auto' \| 'manual' \| 'basic'` | `'manual'` | Композитное поле кода — в Figma нет единого variant-свойства `mode`; в макете это комбинация независимых булевых слоёв `show-header`/`show-body`/`show-message`/`show-progress-bar`/`show-button-send` из API-таблицы. `manual` показывает кнопку «Отправить» (`show-button-send=true`), `auto` — нет (отправка сразу после выбора), `basic` — только кнопка выбора, без зоны дропа/списка/прогресса (`show-body=false`) | +| `dragAndDrop` | `boolean` | `true` | Соответствует Figma-свойству `drag-and-drop`. `true` — зона дропа рисуется **внутри** `p-fileupload-content` (нативный `#content`-враппер PrimeNG), чтобы получить встроенную подсветку `.p-fileupload-highlight` на `dragenter`/`dragover`, без своих обработчиков. `false` — в шапке вместо зоны только кнопка «Выбрать файл», вне `#content`, поэтому drag-and-drop над ней не реагирует (осознанное отключение, а не баг) | +| `labels` | `Labels` | см. спеку | Сгруппированные статические тексты (`dropzoneTitle`/`dropzoneCaption`/`chooseButton`/`uploadButton`/`cancelButton`); в Figma текстовые слои внутри `<🚧FileUpload.Drag-and-Drop>` и кнопок | +| `messages` | `Messages` | см. спеку | Шаблоны текста ошибок валидации (`invalidFileSize`/`invalidFileType`/`invalidFileLimit`), прокидываются в одноимённые `invalid*MessageSummary/Detail` PrimeNG | +| `uploadError` | `string \| null` | `null` | Сообщение об ошибке реальной отправки (nodeId `24805:58632`, «Повтор после сбоя отправки»). Рисуется компонентом на том же месте, что и success-сообщение — сразу после зоны дропа/прогресса, перед списком файлов; выставляет и сбрасывает его приложение (компонент никогда не устанавливает его сам, т.к. всегда работает в `customUpload` и не видит результат реального запроса) | + +Остальные свойства (`name`, `multiple`, `accept`, `maxFileSize`, `fileLimit`, `url`) — 1:1 с +PrimeNG, без переименований. + +## Вложенные компоненты и статус файла + +Каждый файл в очереди хранится как `{ file, status: 'pending'|'uploading'|'success'|'error' }` +(тип `UploadFile` в спеке). Строка рендерит `` рядом с именем/размером: + +| `status` | `severity` бейджа | Текст | +|------------------------|-------------------|-------------| +| `pending` / `uploading` | `info` | «Ожидает» | +| `success` | `success` | «Загружено» | +| `error` | `danger` | «Ошибка» | + +Вся пачка `pending`-файлов переходит в `uploading` одним махом по клику «Отправить» +(`mode='manual'`) или автоматически (`mode='auto'`) — соответствует Figma-аннотации «Все выбранные +файлы уходят одним XHR, поэтому ProgressBar показывает процент всей отправки, а не отдельного +файла» (фрейм «Идёт загрузка», nodeId `24805:56172`). + +## Известные ограничения кита (не реализуются) + +Задокументированы прямо на фрейме «FileUpload — живые примеры» как варианты, для которых нет +рабочего аналога — сознательно не реализованы, а не забыты: + +- **Событие `error`** объявлено по спеке и подключено к PrimeNG `onError`, но поскольку компонент + всегда работает в `customUpload`, встроенный HTTP-путь PrimeNG, который единственный эмитит + `onError`, никогда не выполняется — у этого события нет живого триггера. Показ самой ошибки + реальной отправки при этом **поддержан** через `uploadError` (см. Props mapping выше) — приложение + ловит сбой своего HTTP-вызова и передаёт текст через проп, а не эмитит его само. +- **Пофайловый прогресс и повтор строки** (nodeId `24805:59170`) — `<🚧FileUpload.File>` даёт + только бейдж и кнопку удаления, полосы прогресса или кнопки повтора на уровне строки в ките нет. +- **Молча отброшенный дубликат** (nodeId `24805:59173`) — дедупликация по `name+type+size` + (родная логика PrimeNG `isFileSelected`) не сопровождается никаким сообщением в UI. + +## Do / Don't + +```html + + + + + + + + + + +``` diff --git a/src/stories/components/fileupload/examples/fileupload-default.component.ts b/src/stories/components/fileupload/examples/fileupload-default.component.ts index 56806f20..bba1e31b 100644 --- a/src/stories/components/fileupload/examples/fileupload-default.component.ts +++ b/src/stories/components/fileupload/examples/fileupload-default.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { ExtraFileUploadComponent } from '../../../../lib/components/fileupload/fileupload.component'; +import { ExtraFileUploadComponent, ExtraFileUploadMode } from '../../../../lib/components/fileupload/fileupload.component'; @Component({ selector: 'app-fileupload-default', @@ -7,6 +7,8 @@ import { ExtraFileUploadComponent } from '../../../../lib/components/fileupload/ imports: [ExtraFileUploadComponent], template: ` =400 или сеть) — компонент сам это никак не отслеживает, + * т.к. всегда работает в customUpload: реальный запрос делает приложение и через + * [uploadError] сообщает компоненту текст ошибки, чтобы тот отрисовал его на своём + * штатном месте — сразу после зоны дропа, перед списком файлов (как и success-сообщение). + * Очередь при этом не трогается, повторный клик по «Отправить» — штатный повтор пачки. + */ +@Component({ + selector: 'app-fileupload-upload-error', + standalone: true, + imports: [ExtraFileUploadComponent, ReactiveFormsModule], + template: ` `, +}) +export class FileUploadUploadErrorComponent { + control = new FormControl([ + demoFile('Договор.pdf', 245_000, 'application/pdf'), + demoFile('Смета.docx', 890_000, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), + ]); +} diff --git a/src/stories/components/fileupload/fileupload.stories.ts b/src/stories/components/fileupload/fileupload.stories.ts index d9355e11..645083ad 100644 --- a/src/stories/components/fileupload/fileupload.stories.ts +++ b/src/stories/components/fileupload/fileupload.stories.ts @@ -3,6 +3,7 @@ import { provideHttpClient } from '@angular/common/http'; import { ExtraFileUploadComponent } from '../../../lib/components/fileupload/fileupload.component'; import { FileUploadDefaultComponent } from './examples/fileupload-default.component'; import { FileUploadFormComponent } from './examples/fileupload-form.component'; +import { FileUploadUploadErrorComponent } from './examples/fileupload-upload-error.component'; const meta: Meta = { title: 'Components/Form/FileUpload', @@ -11,7 +12,7 @@ const meta: Meta = { decorators: [ applicationConfig({ providers: [provideHttpClient()] }), moduleMetadata({ - imports: [FileUploadDefaultComponent, FileUploadFormComponent], + imports: [FileUploadDefaultComponent, FileUploadFormComponent, FileUploadUploadErrorComponent], }), ], parameters: { @@ -27,11 +28,30 @@ import { ExtraFileUploadComponent } from '@cdek-it/angular-ui-kit'; designTokens: { prefix: '--p-fileupload' }, }, argTypes: { + mode: { + control: 'select', + options: ['auto', 'manual', 'basic'], + description: 'Режим загрузки; кнопка «Отправить» видна только в manual', + table: { + category: 'Свойства', + defaultValue: { summary: 'manual' }, + type: { summary: "'auto' | 'manual' | 'basic'" }, + }, + }, + dragAndDrop: { + control: 'boolean', + description: 'Включает область drag-and-drop; при false — только кнопка выбора', + table: { + category: 'Свойства', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, multiple: { control: 'boolean', description: 'Разрешает выбирать несколько файлов за один раз', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'true' }, type: { summary: 'boolean' }, }, @@ -40,7 +60,7 @@ import { ExtraFileUploadComponent } from '@cdek-it/angular-ui-kit'; control: 'text', description: 'Шаблон разрешённых типов файлов', table: { - category: 'Props', + category: 'Свойства', type: { summary: 'string' }, }, }, @@ -48,7 +68,7 @@ import { ExtraFileUploadComponent } from '@cdek-it/angular-ui-kit'; control: 'number', description: 'Максимальный размер одного файла в байтах', table: { - category: 'Props', + category: 'Свойства', type: { summary: 'number' }, }, }, @@ -56,25 +76,28 @@ import { ExtraFileUploadComponent } from '@cdek-it/angular-ui-kit'; control: 'boolean', description: 'Отключает возможность выбора и загрузки файлов', table: { - category: 'Props', + category: 'Состояния', defaultValue: { summary: 'false' }, type: { summary: 'boolean' }, }, }, - dropzoneTitle: { table: { disable: true } }, - dropzoneCaption: { table: { disable: true } }, + uploadError: { + control: 'text', + description: 'Текст ошибки реальной отправки; выставляется приложением после своего HTTP-запроса', + table: { + category: 'Состояния', + defaultValue: { summary: 'null' }, + type: { summary: 'string | null' }, + }, + }, name: { table: { disable: true } }, url: { table: { disable: true } }, fileLimit: { table: { disable: true } }, - invalidFileSizeMessageSummary: { table: { disable: true } }, - invalidFileSizeMessageDetail: { table: { disable: true } }, - invalidFileTypeMessageSummary: { table: { disable: true } }, - invalidFileTypeMessageDetail: { table: { disable: true } }, - invalidFileLimitMessageSummary: { table: { disable: true } }, - invalidFileLimitMessageDetail: { table: { disable: true } }, - onSelectEvent: { table: { disable: true } }, - onRemoveEvent: { table: { disable: true } }, - onClearEvent: { table: { disable: true } }, + labels: { table: { disable: true } }, + messages: { table: { disable: true } }, + onSelect: { table: { disable: true } }, + onRemove: { table: { disable: true } }, + onClear: { table: { disable: true } }, onError: { table: { disable: true } }, onUpload: { table: { disable: true } }, }, @@ -118,6 +141,8 @@ export const Default: Story = { name: 'Default', render: (args: any) => ({ props: { + mode: args['mode'], + dragAndDrop: args['dragAndDrop'], multiple: args['multiple'], accept: args['accept'], maxFileSize: args['maxFileSize'], @@ -125,6 +150,8 @@ export const Default: Story = { }, template: ` ({ + template: ``, + }), + parameters: { + docs: { + description: { + story: + 'mode="basic": одна кнопка выбора файла, без зоны дропа, списка и прогресса — приложение само рисует превью, статусы и ошибки. Дроп на саму кнопку по-прежнему работает.', + }, + }, + }, +}; + +export const WithoutDragAndDrop: Story = { + name: 'Without drag-and-drop', + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { + story: 'dragAndDrop="false": вместо зоны дропа — только кнопка «Выбрать файл», перетаскивание файлов недоступно.', + }, + }, + }, +}; + +export const Auto: Story = { + name: 'Auto upload', + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { + story: 'mode="auto": отправка начинается сразу после выбора файлов, без кнопки «Отправить».', + }, + }, + }, +}; + +export const UploadError: Story = { + name: 'Upload error', + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { + story: + 'Ошибка отправки (XHR ≥400 или сеть) — компонент всегда работает в customUpload, реальный запрос и решение об ошибке остаются за приложением. Оно выставляет [uploadError], а компонент рисует сообщение на штатном месте — сразу после зоны дропа, перед списком файлов, как и success-сообщение. Очередь сохраняется, повторный клик по «Отправить» — штатный повтор той же пачки; отдельной кнопки «Повторить» или текста ошибки на уровне строки в ките нет.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ExtraFileUploadComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-example', + standalone: true, + imports: [ExtraFileUploadComponent, ReactiveFormsModule], + template: \` + + \`, +}) +export class ExampleComponent { + control = new FormControl([/* файлы из формы */]); + uploadError: string | null = null; + + onUpload(files: File[]): void { + // реальный HTTP-запрос приложения; при провале — просто: + // this.uploadError = 'Не удалось отправить файлы. Проверьте соединение и повторите попытку'; + } +} + `, + }, + }, + }, +};