Make filling in execution inputs one form, not a stack of cards
Each input opened with a centred gradient badge whose largest text read "Flow" - the same word on every global input, so the most visual weight carried the least information - followed by a "Type:" line and a four-row textarea. A one-word positionTitle got the same box as a CV, and three short answers filled the panel. An input is now one line of chrome - name, a small type chip, and a dot saying whether it is still required - over a field that starts small. Removing an item from a list is an icon rather than a full-width "Remove" button, which at this width used to push the field out of the panel. Saving is one action for the panel. Per-input buttons meant a click and a round trip each, with nothing to say how much was still unsaved; a sticky bar now reports the pending count and saves them together. Each one still goes through the same single-input request, so a failure is still reported against its own input. The panel also stops ignoring missingGlobalInputKeys, which the backend has been sending all along: it now shows "2 of 5 provided" and marks exactly the inputs that block the start. The editor is one template instead of four near-identical copies - global and node, each single and multiple. That was not the goal here, but writing the same change four times is how those four drifted apart in the first place. First tests for this component. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0fe5a0109c
commit
dcf3af5551
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* A form you fill in before starting a run, in a narrow aside. Each input used to open with a
|
||||
* centred gradient badge repeating the word "Flow", a "Type:" line and a four-row textarea, so
|
||||
* three short answers filled the panel. One line of chrome per input, and the field grows instead.
|
||||
*/
|
||||
.inputs-panel-item {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Still required: the backend says this one blocks the start. */
|
||||
.inputs-panel-item-missing {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.inputs-panel-item-pending {
|
||||
border-left-color: #2563eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.inputs-panel-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.inputs-panel-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.inputs-panel-dot-missing {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.inputs-panel-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.inputs-panel-type {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inputs-panel-owner {
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* A plain field, not a Material form-field: those carry ~28px of label and subscript furniture. */
|
||||
.inputs-panel-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.inputs-panel-field:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.inputs-panel-field[readonly] {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.inputs-panel-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inputs-panel-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inputs-panel-index {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 7px;
|
||||
width: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* An icon, not a wide "Remove" button: at this width a text button pushed the field out. */
|
||||
.inputs-panel-remove {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputs-panel-remove:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.inputs-panel-remove:disabled {
|
||||
color: #e2e8f0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputs-panel-remove .mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.inputs-panel-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
align-self: flex-start;
|
||||
padding: 2px 6px 2px 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputs-panel-add:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputs-panel-add .mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
/*
|
||||
* One save for the whole panel. Per-input buttons meant a click and a round trip each, and nothing
|
||||
* ever said how much was still unsaved.
|
||||
*/
|
||||
.inputs-panel-savebar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 4px -12px -12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.inputs-panel-savebar-status {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
|
@ -70,162 +70,119 @@
|
|||
@if (!editableInputs().length && !authorizationRequirements().length) {
|
||||
<div class="text-xs text-slate-500">No manual inputs required.</div>
|
||||
} @else {
|
||||
|
||||
@if (globalExecutionInputs().length) {
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Globals</div>
|
||||
<div class="mt-1 text-[11px] text-slate-500">Provide the shared flow-level inputs once before execution starts.</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Flow inputs</div>
|
||||
<div class="text-[11px] text-slate-500">
|
||||
{{ providedGlobalCount() }} of {{ globalExecutionInputs().length }} provided
|
||||
</div>
|
||||
</div>
|
||||
@for (executionInput of globalExecutionInputs(); track executionInput.key) {
|
||||
<div class="rounded-md border border-violet-200 bg-violet-50/60 p-2">
|
||||
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-violet-200 bg-gradient-to-b from-violet-50 to-violet-100 px-3 py-2 text-center text-violet-700">
|
||||
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
|
||||
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-violet-600">{{ executionInput.subtitle }}</div>
|
||||
</div>
|
||||
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
<input
|
||||
type="file"
|
||||
class="block w-full text-sm text-slate-700"
|
||||
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
} @else {
|
||||
@if (isMultipleInput(executionInput)) {
|
||||
<div class="space-y-3">
|
||||
@for (textValue of textValues(executionInput); track $index) {
|
||||
<div class="flex items-start gap-2">
|
||||
<mat-form-field appearance="outline" class="flex-1">
|
||||
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
|
||||
<textarea matInput rows="3" [readonly]="readOnly()" [ngModel]="textValue" (ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
|
||||
</mat-form-field>
|
||||
<button type="button" mat-stroked-button class="shrink-0" [disabled]="readOnly() || textValues(executionInput).length <= 1" (click)="removeTextItem(executionInput, $index)">Remove</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" mat-stroked-button [disabled]="readOnly()" (click)="addTextItem(executionInput)">Add item</button>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-3">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>{{ executionInput.subtitle }}</mat-label>
|
||||
<textarea matInput rows="4" [readonly]="readOnly()" [ngModel]="executionInput.value" (ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isInputSaving(executionInput.key)) {
|
||||
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
|
||||
}
|
||||
@if (inputSavingError(executionInput.key); as errorMessage) {
|
||||
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
|
||||
}
|
||||
</div>
|
||||
<ng-container *ngTemplateOutlet="inputEditor; context: { $implicit: executionInput }"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (nodeExecutionInputs().length) {
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Manual Inputs</div>
|
||||
}
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Manual inputs</div>
|
||||
@for (executionInput of nodeExecutionInputs(); track executionInput.key) {
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-blue-200 bg-gradient-to-b from-blue-50 to-blue-100 px-3 py-2 text-center text-blue-700">
|
||||
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
|
||||
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-blue-600">{{ executionInput.subtitle }}</div>
|
||||
</div>
|
||||
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
|
||||
<ng-container *ngTemplateOutlet="inputEditor; context: { $implicit: executionInput }"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
<input
|
||||
type="file"
|
||||
class="block w-full text-sm text-slate-700"
|
||||
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
} @else {
|
||||
@if (isMultipleInput(executionInput)) {
|
||||
<div class="space-y-3">
|
||||
@for (textValue of textValues(executionInput); track $index) {
|
||||
<div class="flex items-start gap-2">
|
||||
<mat-form-field appearance="outline" class="flex-1">
|
||||
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="textValue"
|
||||
(ngModelChange)="updateTextItem(executionInput, $index, $event)">
|
||||
</textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="shrink-0"
|
||||
[disabled]="readOnly() || textValues(executionInput).length <= 1"
|
||||
(click)="removeTextItem(executionInput, $index)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
[disabled]="readOnly()"
|
||||
(click)="addTextItem(executionInput)">
|
||||
Add item
|
||||
</button>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
[disabled]="!canSubmitTextInput(executionInput)"
|
||||
(click)="submitTextInput(executionInput, $event)">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (editableInputs().length && !readOnly()) {
|
||||
<div class="inputs-panel-savebar">
|
||||
<span class="inputs-panel-savebar-status">
|
||||
@if (pendingCount()) {
|
||||
{{ pendingCount() }} unsaved {{ pendingCount() === 1 ? 'change' : 'changes' }}
|
||||
} @else {
|
||||
<div class="space-y-3">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>{{ executionInput.subtitle }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="executionInput.value"
|
||||
(ngModelChange)="onTextInputChange(executionInput, $event)">
|
||||
</textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
[disabled]="!canSubmitTextInput(executionInput)"
|
||||
(click)="submitTextInput(executionInput, $event)">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
All changes saved
|
||||
}
|
||||
}
|
||||
|
||||
@if (isInputSaving(executionInput.key)) {
|
||||
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
|
||||
}
|
||||
@if (inputSavingError(executionInput.key); as errorMessage) {
|
||||
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
|
||||
}
|
||||
</span>
|
||||
<button type="button" mat-flat-button [disabled]="!canSubmitAll()" (click)="submitAll($event)">
|
||||
{{ anySaving() ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
One editor for both scopes. It used to be four near-identical copies - global and node, each
|
||||
single and multiple - which is why they drifted apart.
|
||||
-->
|
||||
<ng-template #inputEditor let-executionInput>
|
||||
<div
|
||||
class="inputs-panel-item"
|
||||
[class.inputs-panel-item-missing]="isMissing(executionInput)"
|
||||
[class.inputs-panel-item-pending]="isPending(executionInput)">
|
||||
|
||||
<div class="inputs-panel-item-head">
|
||||
<span
|
||||
class="inputs-panel-dot"
|
||||
[class.inputs-panel-dot-missing]="isMissing(executionInput)"
|
||||
[matTooltip]="isMissing(executionInput) ? 'Still required before this execution can start' : 'Provided'">
|
||||
</span>
|
||||
<span class="inputs-panel-name">{{ executionInput.subtitle }}</span>
|
||||
<span class="inputs-panel-type">{{ inputTypeLabel(executionInput) }}</span>
|
||||
@if (executionInput.scope === 'node') {
|
||||
<span class="inputs-panel-owner" [matTooltip]="'Input of step ' + executionInput.title">
|
||||
{{ executionInput.title }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
<input
|
||||
type="file"
|
||||
class="block w-full text-sm text-slate-700"
|
||||
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
} @else if (isMultipleInput(executionInput)) {
|
||||
<div class="inputs-panel-items">
|
||||
@for (textValue of textValues(executionInput); track $index) {
|
||||
<div class="inputs-panel-row">
|
||||
<span class="inputs-panel-index">{{ $index + 1 }}</span>
|
||||
<textarea
|
||||
class="inputs-panel-field"
|
||||
rows="1"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="textValue"
|
||||
(ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-remove"
|
||||
matTooltip="Remove item"
|
||||
[disabled]="readOnly() || textValues(executionInput).length <= 1"
|
||||
(click)="removeTextItem(executionInput, $index)">
|
||||
<mat-icon fontIcon="close"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-add"
|
||||
[disabled]="readOnly()"
|
||||
(click)="addTextItem(executionInput)">
|
||||
<mat-icon fontIcon="add"></mat-icon>
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<textarea
|
||||
class="inputs-panel-field"
|
||||
rows="2"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="executionInput.value"
|
||||
(ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
|
||||
}
|
||||
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { EditableExecutionInput, TaskExecutionInputsPanelComponent } from './task-execution-inputs-panel';
|
||||
|
||||
function makeInput(overrides: Partial<EditableExecutionInput> = {}): EditableExecutionInput {
|
||||
return {
|
||||
key: 'global:role',
|
||||
scope: 'global',
|
||||
nodeId: null,
|
||||
inputName: 'role',
|
||||
title: 'Flow',
|
||||
subtitle: 'role',
|
||||
type: 'TEXT',
|
||||
multiple: false,
|
||||
value: '',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function build(inputs: EditableExecutionInput[], options: {
|
||||
pendingKeys?: string[];
|
||||
missing?: string[];
|
||||
saving?: Record<string, boolean>;
|
||||
readOnly?: boolean;
|
||||
} = {}) {
|
||||
await TestBed.configureTestingModule({ imports: [TaskExecutionInputsPanelComponent] }).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TaskExecutionInputsPanelComponent);
|
||||
fixture.componentRef.setInput('editableInputs', inputs);
|
||||
fixture.componentRef.setInput('pendingKeys', options.pendingKeys ?? []);
|
||||
fixture.componentRef.setInput('missingGlobalInputNames', options.missing ?? []);
|
||||
fixture.componentRef.setInput('savingInputs', options.saving ?? {});
|
||||
fixture.componentRef.setInput('readOnly', options.readOnly ?? false);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
describe('TaskExecutionInputsPanelComponent', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('reports how many flow inputs are still missing', async () => {
|
||||
const fixture = await build(
|
||||
[makeInput({ key: 'g:a', inputName: 'a' }), makeInput({ key: 'g:b', inputName: 'b' })],
|
||||
{ missing: ['b'] }
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.providedGlobalCount()).toBe(1);
|
||||
expect(fixture.nativeElement.textContent).toContain('1 of 2 provided');
|
||||
});
|
||||
|
||||
it('marks only the inputs the backend still considers unsatisfied', async () => {
|
||||
const provided = makeInput({ key: 'g:a', inputName: 'a' });
|
||||
const missing = makeInput({ key: 'g:b', inputName: 'b' });
|
||||
const fixture = await build([provided, missing], { missing: ['b'] });
|
||||
|
||||
expect(fixture.componentInstance.isMissing(provided)).toBe(false);
|
||||
expect(fixture.componentInstance.isMissing(missing)).toBe(true);
|
||||
});
|
||||
|
||||
it('never marks a node input as missing, since only globals gate the start', async () => {
|
||||
const nodeInput = makeInput({ key: 'n:x', scope: 'node', inputName: 'x', title: 'Reviewer' });
|
||||
const fixture = await build([nodeInput], { missing: ['x'] });
|
||||
|
||||
expect(fixture.componentInstance.isMissing(nodeInput)).toBe(false);
|
||||
});
|
||||
|
||||
it('offers a single save for every pending edit', async () => {
|
||||
const fixture = await build(
|
||||
[makeInput({ key: 'g:a', inputName: 'a' }), makeInput({ key: 'g:b', inputName: 'b' })],
|
||||
{ pendingKeys: ['g:a', 'g:b'] }
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.pendingCount()).toBe(2);
|
||||
expect(fixture.componentInstance.canSubmitAll()).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('2 unsaved changes');
|
||||
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitAllInputs.subscribe(submitted);
|
||||
fixture.componentInstance.submitAll();
|
||||
expect(submitted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says everything is saved, and refuses to save, with nothing pending', async () => {
|
||||
const fixture = await build([makeInput()], {});
|
||||
|
||||
expect(fixture.componentInstance.canSubmitAll()).toBe(false);
|
||||
expect(fixture.nativeElement.textContent).toContain('All changes saved');
|
||||
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitAllInputs.subscribe(submitted);
|
||||
fixture.componentInstance.submitAll();
|
||||
expect(submitted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a second save start while one is in flight', async () => {
|
||||
const fixture = await build([makeInput({ key: 'g:a', inputName: 'a' })],
|
||||
{ pendingKeys: ['g:a'], saving: { 'g:a': true } });
|
||||
|
||||
expect(fixture.componentInstance.anySaving()).toBe(true);
|
||||
expect(fixture.componentInstance.canSubmitAll()).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the save bar when the panel is read-only', async () => {
|
||||
const fixture = await build([makeInput()], { readOnly: true, pendingKeys: ['global:role'] });
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.inputs-panel-savebar')).toBeNull();
|
||||
expect(fixture.componentInstance.canSubmitAll()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders one row per item of a multi-value input, plus a way to add one', async () => {
|
||||
const fixture = await build([makeInput({ multiple: true, type: 'TEXT', value: ['one', 'two'] })]);
|
||||
|
||||
expect(fixture.nativeElement.querySelectorAll('.inputs-panel-row').length).toBe(2);
|
||||
expect(fixture.nativeElement.querySelector('.inputs-panel-add')).not.toBeNull();
|
||||
// The type label says it is a list, which is what the editor is offering.
|
||||
expect(fixture.nativeElement.textContent).toContain('TEXT[]');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
|
|||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
|
||||
|
|
@ -20,8 +22,9 @@ export type EditableExecutionInput = {
|
|||
|
||||
@Component({
|
||||
selector: 'app-task-execution-inputs-panel',
|
||||
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule],
|
||||
templateUrl: './task-execution-inputs-panel.html',
|
||||
styleUrl: './task-execution-inputs-panel.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TaskExecutionInputsPanelComponent {
|
||||
|
|
@ -33,17 +36,52 @@ export class TaskExecutionInputsPanelComponent {
|
|||
readonly savingInputs = input<Record<string, boolean>>({});
|
||||
readonly savingErrors = input<Record<string, string>>({});
|
||||
readonly readOnly = input<boolean>(false);
|
||||
/** Keys the user has edited but not saved; drives the single Save at the foot of the panel. */
|
||||
readonly pendingKeys = input<string[]>([]);
|
||||
/** Names the backend still considers unsatisfied - the ones actually blocking the start. */
|
||||
readonly missingGlobalInputNames = input<string[]>([]);
|
||||
|
||||
readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>();
|
||||
readonly textInputSubmit = output<EditableExecutionInput>();
|
||||
readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>();
|
||||
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
|
||||
readonly authorizationSubmit = output<TaskExecutionAuthorizationRequirement>();
|
||||
readonly submitAllInputs = output<void>();
|
||||
readonly globalExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'global'));
|
||||
readonly nodeExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'node'));
|
||||
|
||||
private readonly authorizationVisibility = new Map<string, boolean>();
|
||||
|
||||
private readonly pendingKeySet = computed(() => new Set(this.pendingKeys()));
|
||||
private readonly missingNameSet = computed(() => new Set(this.missingGlobalInputNames()));
|
||||
|
||||
readonly pendingCount = computed(() => this.editableInputs()
|
||||
.filter((input) => this.pendingKeySet().has(input.key)).length);
|
||||
|
||||
readonly anySaving = computed(() => Object.values(this.savingInputs()).some(Boolean));
|
||||
|
||||
readonly canSubmitAll = computed(() =>
|
||||
!this.readOnly() && this.pendingCount() > 0 && !this.anySaving());
|
||||
|
||||
/** Completion is reported for globals only: those are what gate the start. */
|
||||
readonly providedGlobalCount = computed(() => this.globalExecutionInputs()
|
||||
.filter((input) => !this.isMissing(input)).length);
|
||||
|
||||
isPending(input: EditableExecutionInput): boolean {
|
||||
return this.pendingKeySet().has(input.key);
|
||||
}
|
||||
|
||||
isMissing(input: EditableExecutionInput): boolean {
|
||||
return input.scope === 'global' && this.missingNameSet().has(input.inputName);
|
||||
}
|
||||
|
||||
submitAll(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!this.canSubmitAll()) return;
|
||||
this.submitAllInputs.emit();
|
||||
}
|
||||
|
||||
isFileInput(input: EditableExecutionInput): boolean {
|
||||
return input.type.includes('FILE') || input.type.includes('BINARY');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -399,10 +399,13 @@
|
|||
[savingInputs]="savingInputs()"
|
||||
[savingErrors]="savingErrors()"
|
||||
[readOnly]="inputsReadOnly()"
|
||||
[pendingKeys]="pendingInputKeys()"
|
||||
[missingGlobalInputNames]="missingGlobalInputNames()"
|
||||
(authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)"
|
||||
(authorizationSubmit)="submitAuthorization($event)"
|
||||
(textInputChange)="onTextInputChange($event.input, $event.value)"
|
||||
(textInputSubmit)="submitTextInput($event)"
|
||||
(submitAllInputs)="submitAllTextInputs()"
|
||||
(fileInputChange)="onFileInputChange($event.input, $event.files)">
|
||||
</app-task-execution-inputs-panel>
|
||||
} @else if (activeAsideTab() === 'intermediate') {
|
||||
|
|
|
|||
|
|
@ -137,6 +137,12 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
readonly savingInputs = signal<Record<string, boolean>>({});
|
||||
readonly savingErrors = signal<Record<string, string>>({});
|
||||
readonly pendingTextInputs = signal<Record<string, string | string[]>>({});
|
||||
|
||||
/** Edited but not yet sent, so the panel can offer one Save for the lot. */
|
||||
readonly pendingInputKeys = computed(() => Object.keys(this.pendingTextInputs()));
|
||||
|
||||
/** The backend's own answer on what still blocks the start, by input name. */
|
||||
readonly missingGlobalInputNames = computed(() => this.execution()?.missingGlobalInputKeys ?? []);
|
||||
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
|
||||
readonly savingAuthorizations = signal<Record<string, boolean>>({});
|
||||
readonly authorizationErrors = signal<Record<string, string>>({});
|
||||
|
|
@ -1232,6 +1238,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
this.authorizationErrors.update((current) => ({ ...current, [key]: message }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves every edited input in one go. Each still goes through the same single-input request the
|
||||
* per-field button used - only the trigger is shared - so a failure is reported per input.
|
||||
*/
|
||||
submitAllTextInputs() {
|
||||
if (this.inputsReadOnly()) return;
|
||||
const pending = new Set(Object.keys(this.pendingTextInputs()));
|
||||
this.editableInputs()
|
||||
.filter((input) => pending.has(input.key))
|
||||
.forEach((input) => this.submitTextInput(input));
|
||||
}
|
||||
|
||||
private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) {
|
||||
if (this.inputsReadOnly() || this.execution()?.id !== executionId) return;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue