Fold a list input away, and edit a long value in a box that fits it
The import action moves onto the name line: it was a flex container in a baseline-aligned row, so it floated above the name and the type chip. It also loses the data_array glyph for a plain upload arrow, since the dialog it opens explains itself. A list input now folds, on the same rule the two groups follow: unfolded while it still needs attention, folded once it is satisfied. Five long answers filled the whole aside otherwise. Folded, the row says how many items it is hiding, so nothing disappears silently. Long values were being typed through a slit ~320px wide. Every text value now offers a larger box - on the head for a single value, per row for one item of a list. Both write back through the ordinary pending change, so the panel's single Save still decides when anything is sent, and the box is read-only when the panel is. Both dialogs now resolve the input by key when applying: editableInputs is rebuilt on every poll, so the object captured when the dialog opened could be a stale value to rebase an edit onto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df8c122c12
commit
9d3414d3cb
|
|
@ -86,12 +86,50 @@
|
|||
|
||||
.inputs-panel-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.inputs-panel-fold {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: -4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputs-panel-fold:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.inputs-panel-fold .mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.inputs-panel-count {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inputs-panel-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
|
|
@ -192,7 +230,6 @@
|
|||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
|
@ -270,8 +307,11 @@
|
|||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Pasting a JSON array beats typing five long questions one box at a time. */
|
||||
.inputs-panel-import {
|
||||
/*
|
||||
* The two secondary actions on a value: paste a whole JSON array instead of typing five long
|
||||
* questions one box at a time, and open the value in a box wide enough to read it.
|
||||
*/
|
||||
.inputs-panel-icon {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
|
@ -286,23 +326,28 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputs-panel-import:hover:not(:disabled) {
|
||||
.inputs-panel-icon:hover:not(:disabled) {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.inputs-panel-import:disabled {
|
||||
.inputs-panel-icon:disabled {
|
||||
color: #e2e8f0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputs-panel-import .mat-icon {
|
||||
.inputs-panel-icon .mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
/* On an item row the actions sit beside a textarea, so they line up with its first line. */
|
||||
.inputs-panel-row-action {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.inputs-panel-import-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
|
@ -349,3 +394,28 @@
|
|||
gap: 8px;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.inputs-panel-editor-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.inputs-panel-editor-field:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.inputs-panel-editor-field[readonly] {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,18 @@
|
|||
[class.inputs-panel-item-pending]="isPending(executionInput)">
|
||||
|
||||
<div class="inputs-panel-item-head">
|
||||
@if (isListInput(executionInput)) {
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-fold"
|
||||
[attr.aria-expanded]="itemsOpen(executionInput)"
|
||||
[attr.aria-label]="(itemsOpen(executionInput) ? 'Collapse ' : 'Expand ') + executionInput.subtitle"
|
||||
[matTooltip]="itemsOpen(executionInput) ? 'Collapse items' : 'Expand items'"
|
||||
(click)="toggleItems(executionInput, $event)">
|
||||
<mat-icon [fontIcon]="itemsOpen(executionInput) ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<span
|
||||
class="inputs-panel-dot"
|
||||
[class.inputs-panel-dot-missing]="isMissing(executionInput)"
|
||||
|
|
@ -139,17 +151,33 @@
|
|||
</span>
|
||||
<span class="inputs-panel-name">{{ executionInput.subtitle }}</span>
|
||||
<span class="inputs-panel-type">{{ inputTypeLabel(executionInput) }}</span>
|
||||
@if (isMultipleInput(executionInput) && !isFileInput(executionInput)) {
|
||||
|
||||
<!-- Collapsed, the row must still say how much it is hiding. -->
|
||||
@if (isListInput(executionInput) && !itemsOpen(executionInput)) {
|
||||
<span class="inputs-panel-count">{{ itemCountLabel(executionInput) }}</span>
|
||||
}
|
||||
|
||||
@if (isListInput(executionInput)) {
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-import"
|
||||
class="inputs-panel-icon inputs-panel-import"
|
||||
matTooltip="Import from JSON array"
|
||||
[attr.aria-label]="'Import ' + executionInput.subtitle + ' from a JSON array'"
|
||||
[disabled]="readOnly()"
|
||||
(click)="openImport(executionInput, $event)">
|
||||
<mat-icon fontIcon="data_array"></mat-icon>
|
||||
<mat-icon fontIcon="upload"></mat-icon>
|
||||
</button>
|
||||
} @else if (!isFileInput(executionInput)) {
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-icon"
|
||||
matTooltip="Edit in a larger box"
|
||||
[attr.aria-label]="'Edit ' + executionInput.subtitle + ' in a larger box'"
|
||||
(click)="openEditor(executionInput, null, $event)">
|
||||
<mat-icon fontIcon="open_in_full"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (executionInput.scope === 'node') {
|
||||
<span class="inputs-panel-owner" [matTooltip]="'Input of step ' + executionInput.title">
|
||||
{{ executionInput.title }}
|
||||
|
|
@ -165,6 +193,7 @@
|
|||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
} @else if (isMultipleInput(executionInput)) {
|
||||
@if (itemsOpen(executionInput)) {
|
||||
<div class="inputs-panel-items">
|
||||
@for (textValue of textValues(executionInput); track $index) {
|
||||
<div class="inputs-panel-row">
|
||||
|
|
@ -177,7 +206,15 @@
|
|||
(ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-remove"
|
||||
class="inputs-panel-icon inputs-panel-row-action"
|
||||
matTooltip="Edit in a larger box"
|
||||
[attr.aria-label]="'Edit item ' + ($index + 1) + ' of ' + executionInput.subtitle + ' in a larger box'"
|
||||
(click)="openEditor(executionInput, $index, $event)">
|
||||
<mat-icon fontIcon="open_in_full"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-remove inputs-panel-row-action"
|
||||
matTooltip="Remove item"
|
||||
[disabled]="readOnly() || textValues(executionInput).length <= 1"
|
||||
(click)="removeTextItem(executionInput, $index)">
|
||||
|
|
@ -194,6 +231,7 @@
|
|||
Add item
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<textarea
|
||||
class="inputs-panel-field"
|
||||
|
|
@ -258,3 +296,31 @@
|
|||
</footer>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
||||
<!--
|
||||
The panel lives in a ~320px aside, so a long answer is edited through a slit. This is the same
|
||||
value, in a box with room to read it.
|
||||
-->
|
||||
@if (editorTarget(); as target) {
|
||||
<app-modal-shell
|
||||
[title]="'Edit ' + target.input.subtitle"
|
||||
[subtitle]="editorSubtitle()"
|
||||
ariaLabel="Edit the input value in a larger box"
|
||||
maxWidth="720px"
|
||||
closeLabel="Cancel"
|
||||
(backdropClick)="closeEditor()"
|
||||
(closeClick)="closeEditor()">
|
||||
|
||||
<textarea
|
||||
class="inputs-panel-editor-field"
|
||||
rows="16"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="editorText()"
|
||||
(ngModelChange)="editorText.set($event)"></textarea>
|
||||
|
||||
<footer class="inputs-panel-import-footer">
|
||||
<button type="button" mat-stroked-button (click)="closeEditor()">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" [disabled]="readOnly()" (click)="applyEditor()">Done</button>
|
||||
</footer>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,87 @@ describe('TaskExecutionInputsPanelComponent', () => {
|
|||
expect(fixture.componentInstance.canSubmitAll()).toBe(true);
|
||||
});
|
||||
|
||||
it('unfolds a list that still needs attention and folds a satisfied one', async () => {
|
||||
// Five long answers otherwise fill the whole aside, so a finished list gets out of the way.
|
||||
const missing = makeInput({ key: 'g:many', inputName: 'questions', multiple: true, value: ['', ''] });
|
||||
const done = makeInput({
|
||||
key: 'g:done', inputName: 'answers', multiple: true, value: ['a', 'b'], provided: true
|
||||
});
|
||||
const fixture = await build([missing, done]);
|
||||
|
||||
expect(fixture.componentInstance.itemsOpen(missing)).toBe(true);
|
||||
expect(fixture.componentInstance.itemsOpen(done)).toBe(false);
|
||||
// Only the unfolded one renders its rows.
|
||||
expect(fixture.nativeElement.querySelectorAll('.inputs-panel-row').length).toBe(2);
|
||||
// Folded, it still says how much it is hiding.
|
||||
expect(fixture.nativeElement.textContent).toContain('2 items');
|
||||
});
|
||||
|
||||
it('lets the user fold either list, in both directions', async () => {
|
||||
const missing = makeInput({ key: 'g:many', inputName: 'questions', multiple: true, value: [''] });
|
||||
const fixture = await build([missing]);
|
||||
|
||||
fixture.componentInstance.toggleItems(missing);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.itemsOpen(missing)).toBe(false);
|
||||
expect(fixture.nativeElement.querySelectorAll('.inputs-panel-row').length).toBe(0);
|
||||
expect(fixture.nativeElement.textContent).toContain('1 item');
|
||||
|
||||
fixture.componentInstance.toggleItems(missing);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.itemsOpen(missing)).toBe(true);
|
||||
});
|
||||
|
||||
it('offers a larger box for a single value, and edits it through the pending change', async () => {
|
||||
const input = makeInput({ key: 'g:brief', inputName: 'jobRequirements', value: 'short' });
|
||||
const fixture = await build([input]);
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.textInputChange.subscribe(changed);
|
||||
|
||||
const enlarge = fixture.nativeElement.querySelector('.inputs-panel-icon:not(.inputs-panel-import)');
|
||||
expect(enlarge).not.toBeNull();
|
||||
|
||||
fixture.componentInstance.openEditor(input, null, new Event('click'));
|
||||
// It opens on the value that is there, rather than on an empty box.
|
||||
expect(fixture.componentInstance.editorText()).toBe('short');
|
||||
|
||||
fixture.componentInstance.editorText.set('a much longer requirement');
|
||||
fixture.componentInstance.applyEditor();
|
||||
|
||||
expect(changed).toHaveBeenCalledWith({ input, value: 'a much longer requirement' });
|
||||
expect(fixture.componentInstance.editorTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('edits one item of a list in the larger box, leaving its siblings alone', async () => {
|
||||
const input = makeInput({
|
||||
key: 'g:many', inputName: 'questions', multiple: true, value: ['first', 'second']
|
||||
});
|
||||
const fixture = await build([input]);
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.textInputChange.subscribe(changed);
|
||||
|
||||
fixture.componentInstance.openEditor(input, 1, new Event('click'));
|
||||
expect(fixture.componentInstance.editorText()).toBe('second');
|
||||
|
||||
fixture.componentInstance.editorText.set('second, at length');
|
||||
fixture.componentInstance.applyEditor();
|
||||
|
||||
expect(changed).toHaveBeenCalledWith({ input, value: ['first', 'second, at length'] });
|
||||
});
|
||||
|
||||
it('does not write back from the larger box while the panel is read-only', async () => {
|
||||
const input = makeInput({ key: 'g:brief', inputName: 'jobRequirements', value: 'short' });
|
||||
const fixture = await build([input], { readOnly: true });
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.textInputChange.subscribe(changed);
|
||||
|
||||
fixture.componentInstance.openEditor(input, null, new Event('click'));
|
||||
fixture.componentInstance.editorText.set('edited anyway');
|
||||
fixture.componentInstance.applyEditor();
|
||||
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers the JSON import only on a multi-value input', async () => {
|
||||
const fixture = await build([
|
||||
makeInput({ key: 'g:single', inputName: 'positionTitle' }),
|
||||
|
|
|
|||
|
|
@ -140,6 +140,76 @@ export class TaskExecutionInputsPanelComponent {
|
|||
this.nodesOverride.set(!this.nodesOpen());
|
||||
}
|
||||
|
||||
/**
|
||||
* A list input is folded once it is satisfied and unfolded while it still needs attention - the
|
||||
* same rule the two groups follow. Five long answers otherwise fill the whole aside on their own.
|
||||
*/
|
||||
private readonly itemsOverrides = signal<Record<string, boolean>>({});
|
||||
|
||||
itemsOpen(input: EditableExecutionInput): boolean {
|
||||
return this.itemsOverrides()[input.key] ?? !input.provided;
|
||||
}
|
||||
|
||||
toggleItems(input: EditableExecutionInput, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
this.itemsOverrides.update((current) => ({ ...current, [input.key]: !this.itemsOpen(input) }));
|
||||
}
|
||||
|
||||
itemCountLabel(input: EditableExecutionInput): string {
|
||||
const count = this.textValues(input).length;
|
||||
return count === 1 ? '1 item' : `${count} items`;
|
||||
}
|
||||
|
||||
/** A list of texts: the file inputs are multiple too, but the browser picker handles those. */
|
||||
isListInput(input: EditableExecutionInput): boolean {
|
||||
return this.isMultipleInput(input) && !this.isFileInput(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value being edited in the large box: `index` names one item of a list input, null the
|
||||
* whole single-valued input.
|
||||
*/
|
||||
readonly editorTarget = signal<{ input: EditableExecutionInput; index: number | null } | null>(null);
|
||||
readonly editorText = signal('');
|
||||
|
||||
readonly editorSubtitle = computed(() => {
|
||||
const target = this.editorTarget();
|
||||
if (!target) return null;
|
||||
return target.index === null
|
||||
? `Value of ${target.input.subtitle}.`
|
||||
: `Item ${target.index + 1} of ${target.input.subtitle}.`;
|
||||
});
|
||||
|
||||
openEditor(input: EditableExecutionInput, index: number | null, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const current = index === null
|
||||
? (Array.isArray(input.value) ? input.value.join('\n') : input.value ?? '')
|
||||
: this.textValues(input)[index] ?? '';
|
||||
this.editorTarget.set({ input, index });
|
||||
this.editorText.set(current);
|
||||
}
|
||||
|
||||
closeEditor() {
|
||||
this.editorTarget.set(null);
|
||||
}
|
||||
|
||||
applyEditor() {
|
||||
const target = this.editorTarget();
|
||||
if (!target || this.readOnly()) return;
|
||||
|
||||
// Routed through the ordinary edit path, so the box is only a bigger way to type: the panel's
|
||||
// single Save still decides when the value is sent.
|
||||
const input = this.liveInput(target.input);
|
||||
if (target.index === null) {
|
||||
this.onTextInputChange(input, this.editorText());
|
||||
} else {
|
||||
this.updateTextItem(input, target.index, this.editorText());
|
||||
}
|
||||
this.closeEditor();
|
||||
}
|
||||
|
||||
readonly importTarget = signal<EditableExecutionInput | null>(null);
|
||||
readonly importText = signal('');
|
||||
readonly importError = signal<string | null>(null);
|
||||
|
|
@ -168,10 +238,18 @@ export class TaskExecutionInputsPanelComponent {
|
|||
|
||||
// Emitted like any other edit, so the imported items land in the panel's single Save rather
|
||||
// than being written straight through.
|
||||
this.onTextInputChange(input, result.values);
|
||||
this.onTextInputChange(this.liveInput(input), result.values);
|
||||
this.closeImport();
|
||||
}
|
||||
|
||||
/**
|
||||
* The current copy of an input the dialogs were opened on. `editableInputs` is rebuilt on every
|
||||
* poll, so the captured object can be a stale snapshot to rebase an edit onto.
|
||||
*/
|
||||
private liveInput(input: EditableExecutionInput): EditableExecutionInput {
|
||||
return this.editableInputs().find((candidate) => candidate.key === input.key) ?? input;
|
||||
}
|
||||
|
||||
isPending(input: EditableExecutionInput): boolean {
|
||||
return this.pendingKeySet().has(input.key);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue