Give a file input a picker that says what it takes and what went wrong
The native file control said "Choose file / No file chosen": nothing about what the input accepts, nothing about the file once chosen, and on failure a bare "Failed to upload file" that threw away the server's explanation - the wrong type, a size, a name it could not build a file from. Replace it with a drop zone that states the accepted types and size up front, then gives way to the file itself while it uploads and once it lands, with Replace to change it. A failed upload now names the file that failed next to what the server actually said, and the zone invites another try - which also needed the native control cleared, since picking the same file twice fires no change event and picking the same file again is exactly what a retry is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f41962993c
commit
84f528203c
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -204,15 +204,79 @@
|
|||
</div>
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
<!--
|
||||
The native control is kept for what only it can do - open the picker, carry `accept` - and
|
||||
hidden, because its own "Choose file / No file chosen" says nothing about what this input
|
||||
takes, what was chosen, or whether the upload got there.
|
||||
-->
|
||||
<input
|
||||
#filePicker
|
||||
type="file"
|
||||
class="block w-full text-sm text-slate-700"
|
||||
class="inputs-panel-upload-native"
|
||||
[attr.accept]="fileAccept(executionInput)"
|
||||
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
@if (fileInputHint(executionInput); as hint) {
|
||||
<div class="mt-1 text-[11px] text-slate-500">{{ hint }}</div>
|
||||
|
||||
@if (isInputSaving(executionInput.key)) {
|
||||
<div class="inputs-panel-upload-card inputs-panel-upload-busy">
|
||||
<!-- autorenew, not progress_activity: the latter is a Material Symbols name and this app
|
||||
ships the classic Material Icons font, where it renders as nothing at all. -->
|
||||
<mat-icon class="inputs-panel-upload-spinner" fontIcon="autorenew"></mat-icon>
|
||||
<span class="inputs-panel-upload-name">{{ uploadFileLabel(executionInput) }}</span>
|
||||
<span class="inputs-panel-upload-state">Uploading…</span>
|
||||
</div>
|
||||
} @else if (executionInput.provided && !inputSavingError(executionInput.key)) {
|
||||
<div class="inputs-panel-upload-card inputs-panel-upload-done">
|
||||
<mat-icon fontIcon="task_alt"></mat-icon>
|
||||
<span class="inputs-panel-upload-name" [matTooltip]="uploadFileLabel(executionInput)">
|
||||
{{ uploadFileLabel(executionInput) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-upload-replace"
|
||||
[disabled]="readOnly()"
|
||||
[attr.aria-label]="'Replace the file uploaded for ' + executionInput.subtitle"
|
||||
(click)="filePicker.click()">
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-dropzone"
|
||||
[class.inputs-panel-dropzone-active]="isDragging(executionInput)"
|
||||
[class.inputs-panel-dropzone-failed]="!!inputSavingError(executionInput.key)"
|
||||
[disabled]="readOnly()"
|
||||
[attr.aria-label]="'Choose a file for ' + executionInput.subtitle"
|
||||
(click)="filePicker.click()"
|
||||
(dragover)="onDragOver(executionInput, $event)"
|
||||
(dragleave)="onDragLeave(executionInput, $event)"
|
||||
(drop)="onFileDrop(executionInput, $event)">
|
||||
<mat-icon [fontIcon]="inputSavingError(executionInput.key) ? 'refresh' : 'cloud_upload'"></mat-icon>
|
||||
<span class="inputs-panel-dropzone-title">
|
||||
@if (inputSavingError(executionInput.key)) {
|
||||
Choose another file, or drop it here
|
||||
} @else if (isMultipleInput(executionInput)) {
|
||||
Drop files here, or click to choose
|
||||
} @else {
|
||||
Drop a file here, or click to choose
|
||||
}
|
||||
</span>
|
||||
@if (fileInputHint(executionInput); as hint) {
|
||||
<span class="inputs-panel-dropzone-hint">{{ hint }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (inputSavingError(executionInput.key); as uploadError) {
|
||||
<div class="inputs-panel-upload-error">
|
||||
<mat-icon fontIcon="error_outline"></mat-icon>
|
||||
<span>
|
||||
<strong>{{ uploadFileLabel(executionInput) }}</strong> was not uploaded.
|
||||
{{ uploadError }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
} @else if (isMultipleInput(executionInput)) {
|
||||
@if (itemsOpen(executionInput)) {
|
||||
|
|
@ -263,12 +327,15 @@
|
|||
(ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
|
||||
}
|
||||
|
||||
<!-- A file input reports both of these inside its own card, next to the file they are about. -->
|
||||
@if (!isFileInput(executionInput)) {
|
||||
@if (isInputSaving(executionInput.key)) {
|
||||
<div class="mt-1 text-[11px] text-blue-600">Saving…</div>
|
||||
}
|
||||
@if (inputSavingError(executionInput.key); as errorMessage) {
|
||||
<div class="mt-1 text-[11px] text-red-600">{{ errorMessage }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>();
|
||||
|
||||
/**
|
||||
* 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<Record<string, string[]>>({});
|
||||
|
||||
/** Which drop zone the pointer is currently over, so only that one lights up. */
|
||||
private readonly draggingKey = signal<string | null>(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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue