diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css index fbe2606..88e510f 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css @@ -200,6 +200,204 @@ color: #64748b; } +/* + * The upload block. The native file control stays in the DOM - only it can open the picker and + * carry `accept` - but off-screen rather than display:none, so it keeps working for assistive + * technology and for tests that drive it directly. + */ +.inputs-panel-upload-native { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.inputs-panel-dropzone { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + width: 100%; + padding: 14px 10px; + border: 1px dashed #cbd5e1; + border-radius: 6px; + background: #f8fafc; + color: #475569; + font: inherit; + text-align: center; + cursor: pointer; + transition: border-color 120ms ease, background 120ms ease, color 120ms ease; +} + +.inputs-panel-dropzone:hover:not(:disabled), +.inputs-panel-dropzone:focus-visible { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; + outline: none; +} + +/* Dragging over it has to read as a target, not just as hover. */ +.inputs-panel-dropzone-active { + border-color: #2563eb; + border-style: solid; + background: #dbeafe; + color: #1d4ed8; +} + +.inputs-panel-dropzone-failed { + border-color: #fca5a5; + background: #fef2f2; + color: #b91c1c; +} + +.inputs-panel-dropzone:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.inputs-panel-dropzone .mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; +} + +.inputs-panel-dropzone-title { + font-size: 12px; + font-weight: 500; +} + +.inputs-panel-dropzone-hint { + color: #94a3b8; + font-size: 10.5px; +} + +/* Once a file is chosen the zone gives way to the file itself: name first, state second. */ +.inputs-panel-upload-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + font-size: 12px; +} + +.inputs-panel-upload-card .mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; +} + +.inputs-panel-upload-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #0f172a; + font-weight: 500; +} + +.inputs-panel-upload-state { + flex: 0 0 auto; + color: #2563eb; + font-size: 11px; +} + +.inputs-panel-upload-busy { + border-color: #bfdbfe; + background: #eff6ff; +} + +.inputs-panel-upload-busy .mat-icon { + color: #2563eb; +} + +.inputs-panel-upload-done { + border-color: #bbf7d0; + background: #f0fdf4; +} + +.inputs-panel-upload-done .mat-icon { + color: #16a34a; +} + +.inputs-panel-upload-spinner { + animation: inputs-panel-upload-spin 900ms linear infinite; +} + +@keyframes inputs-panel-upload-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .inputs-panel-upload-spinner { + animation: none; + } +} + +.inputs-panel-upload-replace { + flex: 0 0 auto; + padding: 2px 8px; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + color: #334155; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.inputs-panel-upload-replace:hover:not(:disabled) { + border-color: #94a3b8; + color: #0f172a; +} + +.inputs-panel-upload-replace:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +/* Names the file that failed, not just that something did, and says what the server said. */ +.inputs-panel-upload-error { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 6px; + padding: 8px 10px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 11.5px; + line-height: 1.45; +} + +.inputs-panel-upload-error .mat-icon { + flex: 0 0 auto; + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + +.inputs-panel-upload-error strong { + font-weight: 600; + word-break: break-all; +} + .inputs-panel-items { display: flex; flex-direction: column; diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index 9145b84..c6c167b 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -204,15 +204,79 @@ @if (isFileInput(executionInput)) { + - @if (fileInputHint(executionInput); as hint) { -
{{ hint }}
+ + @if (isInputSaving(executionInput.key)) { +
+ + + {{ uploadFileLabel(executionInput) }} + Uploading… +
+ } @else if (executionInput.provided && !inputSavingError(executionInput.key)) { +
+ + + {{ uploadFileLabel(executionInput) }} + + +
+ } @else { + + } + + @if (inputSavingError(executionInput.key); as uploadError) { +
+ + + {{ uploadFileLabel(executionInput) }} was not uploaded. + {{ uploadError }} + +
} } @else if (isMultipleInput(executionInput)) { @if (itemsOpen(executionInput)) { @@ -263,12 +327,15 @@ (ngModelChange)="onTextInputChange(executionInput, $event)"> } + + @if (!isFileInput(executionInput)) { @if (isInputSaving(executionInput.key)) {
Saving…
} @if (inputSavingError(executionInput.key); as errorMessage) {
{{ errorMessage }}
} + } diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts index a139204..22339f1 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts @@ -101,7 +101,38 @@ describe('TaskExecutionInputsPanelComponent', () => { const picker = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; expect(picker.accept).toBe('application/pdf'); - expect(fixture.nativeElement.textContent).toContain('Allowed: PDF; up to 4 files; total up to 32,000,000 bytes.'); + // Said before a file is picked, in the units someone reads a file size in. + expect(fixture.nativeElement.textContent).toContain('PDF · up to 4 files · max 32 MB'); + expect(fixture.nativeElement.querySelector('.inputs-panel-dropzone')).toBeTruthy(); + }); + + it('names the file it is uploading, and what it uploaded, instead of the native picker', async () => { + const fixture = await build([makeInput({ + key: 'global:document', + inputName: 'document', + type: 'FILE', + multiple: false, + fileConstraints: { acceptedMediaTypes: ['application/pdf'], maxFiles: 1, maxTotalBytes: 32_000_000 } + })]); + + const picker = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF-1.7'], 'plan-01.pdf', { type: 'application/pdf' }); + Object.defineProperty(picker, 'files', { value: [file], configurable: true }); + picker.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + fixture.componentRef.setInput('savingInputs', { 'global:document': true }); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('plan-01.pdf'); + expect(fixture.nativeElement.textContent).toContain('Uploading'); + + fixture.componentRef.setInput('savingInputs', {}); + fixture.componentRef.setInput('savingErrors', { 'global:document': 'Only PDF files are accepted.' }); + fixture.detectChanges(); + // The file that failed is named next to why, and the zone invites another try. + expect(fixture.nativeElement.textContent).toContain('plan-01.pdf'); + expect(fixture.nativeElement.textContent).toContain('Only PDF files are accepted.'); + expect(fixture.nativeElement.querySelector('.inputs-panel-upload-error')).toBeTruthy(); }); it('counts every input, node ones included, since all of them are required', async () => { diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index 329f9f9..c66918d 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -93,6 +93,34 @@ function formatBytes(bytes: number): string { return `${new Intl.NumberFormat('en-US').format(bytes)} bytes`; } +/** + * Sizes for the hint above the picker, where the point is to set expectations before a file is + * chosen. A rejection still reports exact bytes: there, the difference between 32,000,000 and + * 32,000,001 is the whole reason the file was refused. + */ +function formatBytesShort(bytes: number): string { + const units: [number, string][] = [[1_000_000_000, 'GB'], [1_000_000, 'MB'], [1_000, 'KB']]; + for (const [scale, unit] of units) { + if (bytes >= scale) { + const value = bytes / scale; + return `${Number.isInteger(value) ? value : value.toFixed(1)} ${unit}`; + } + } + return `${bytes} bytes`; +} + +/** + * The name to show for a file the run already holds. The stored value is the server's temp path, + * whose name is the input's name, then digits, then the original filename - stripping that lead-in + * is what gets back to the name the person recognises. + */ +function storedFileName(storedPath: string, inputName: string): string { + const base = storedPath.split(/[\\/]/).pop() ?? storedPath; + const escapedInputName = inputName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const stripped = base.replace(new RegExp(`^${escapedInputName}_*\\d+`), ''); + return stripped.trim().length ? stripped : base; +} + function mediaTypeLabel(mediaTypes: string[]): string { const labels = mediaTypes.map((mediaType) => { if (mediaType === 'image/png') return 'PNG'; @@ -187,6 +215,15 @@ export class TaskExecutionInputsPanelComponent { private readonly authorizationVisibility = new Map(); + /** + * What was picked, per input key. The native control reports a file once and the panel has to keep + * naming it afterwards - while it uploads, and after the value stored for it is a server path. + */ + private readonly selectedFileNames = signal>({}); + + /** Which drop zone the pointer is currently over, so only that one lights up. */ + private readonly draggingKey = signal(null); + private readonly pendingKeySet = computed(() => new Set(this.pendingKeys())); readonly pendingCount = computed(() => this.editableInputs() @@ -378,15 +415,57 @@ export class TaskExecutionInputsPanelComponent { return mediaTypes.length ? mediaTypes.join(',') : null; } + /** + * What this input will accept, said before a file is chosen rather than after one is refused. + * A file count is only worth saying on an input that takes more than one. + */ fileInputHint(input: EditableExecutionInput): string | null { const constraints = input.fileConstraints; if (!constraints) return null; const parts: string[] = []; const mediaTypes = constraints.acceptedMediaTypes ?? []; - if (mediaTypes.length) parts.push(`Allowed: ${mediaTypeLabel(mediaTypes)}`); - if (constraints.maxFiles != null) parts.push(`up to ${constraints.maxFiles} files`); - if (constraints.maxTotalBytes != null) parts.push(`total up to ${formatBytes(constraints.maxTotalBytes)}`); - return parts.length ? `${parts.join('; ')}.` : null; + if (mediaTypes.length) parts.push(mediaTypeLabel(mediaTypes)); + if (input.multiple && constraints.maxFiles != null) parts.push(`up to ${constraints.maxFiles} files`); + if (constraints.maxTotalBytes != null) parts.push(`max ${formatBytesShort(constraints.maxTotalBytes)}`); + return parts.length ? parts.join(' · ') : null; + } + + isDragging(input: EditableExecutionInput): boolean { + return this.draggingKey() === input.key; + } + + onDragOver(input: EditableExecutionInput, event: DragEvent) { + if (this.readOnly()) return; + event.preventDefault(); + this.draggingKey.set(input.key); + } + + onDragLeave(input: EditableExecutionInput, event: DragEvent) { + event.preventDefault(); + if (this.draggingKey() === input.key) { + this.draggingKey.set(null); + } + } + + onFileDrop(input: EditableExecutionInput, event: DragEvent) { + event.preventDefault(); + this.draggingKey.set(null); + if (this.readOnly()) return; + const files = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []; + if (files.length) { + this.emitFiles(input, files); + } + } + + /** The name to show for a file input: what was picked here, else what the run already holds. */ + uploadFileLabel(input: EditableExecutionInput): string { + const picked = this.selectedFileNames()[input.key] ?? []; + if (picked.length) return picked.join(', '); + const values = Array.isArray(input.value) ? input.value : [input.value]; + const names = values + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .map((value) => storedFileName(value, input.inputName)); + return names.length ? names.join(', ') : 'Uploaded file'; } textValues(input: EditableExecutionInput): string[] { @@ -409,10 +488,19 @@ export class TaskExecutionInputsPanelComponent { } onFileInputChange(input: EditableExecutionInput, event: Event) { - if (this.readOnly()) return; const target = event.target as HTMLInputElement | null; const files = target?.files ? Array.from(target.files) : []; - if (!files.length) return; + // Clearing the control is what makes a retry possible: picking the same file twice fires no + // change event, and picking the same file again is exactly what follows a failed upload. + if (target) { + target.value = ''; + } + if (this.readOnly() || !files.length) return; + this.emitFiles(input, files); + } + + private emitFiles(input: EditableExecutionInput, files: File[]) { + this.selectedFileNames.update((current) => ({ ...current, [input.key]: files.map((file) => file.name) })); this.fileInputChange.emit({ input, files }); } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 028d992..12b2a2a 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -105,6 +105,25 @@ import { resolveExecutionDependencies } from './execution-graph'; +/** + * What to tell someone whose upload was refused. The API answers a rejection with a `detail` saying + * why - the type it will not take, the size, a name it cannot build a file from - and that sentence + * is the only part they can act on, so it is what gets shown. The generic line is the fallback for + * a failure that arrived without one, a dropped connection being the usual case. + */ +function uploadFailureMessage(failure: unknown): string { + const body = (failure as { error?: unknown } | null)?.error; + const detail = typeof body === 'string' + ? body + : (body as { detail?: unknown; message?: unknown } | null)?.detail + ?? (body as { detail?: unknown; message?: unknown } | null)?.message; + const text = typeof detail === 'string' ? detail.trim() : ''; + if (!text || text.startsWith('<')) { + return 'The upload did not reach the server. Check the connection and try again.'; + } + return text.endsWith('.') ? text : `${text}.`; +} + @Component({ selector: 'app-task-execution-viewer', imports: [CommonModule, FormsModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatSelectModule, BiasImpactReportListComponent, JsonViewerComponent], @@ -1289,7 +1308,9 @@ export class TaskExecutionViewerComponent implements OnDestroy { request$.subscribe({ next: () => this.clearInputSaving(input.key), - error: () => this.setInputError(input.key, 'Failed to upload file') + // The server explains a refused upload - the wrong type, a size, a name it cannot use. Saying + // only "Failed to upload file" threw all of that away and left nothing to act on. + error: (failure) => this.setInputError(input.key, uploadFailureMessage(failure)) }); }