diff --git a/app/guid-node/metadata/template.hbs b/app/guid-node/metadata/template.hbs index 1d9f3eaf4..515356713 100644 --- a/app/guid-node/metadata/template.hbs +++ b/app/guid-node/metadata/template.hbs @@ -91,11 +91,11 @@ @changed={{action this.schemaChanged}} >
- {{schema.name}} + {{schema.localizedName}} {{fa-icon 'info-circle'}} - {{schema.schema.description}} + {{schema.localizedDescription}}
diff --git a/app/guid-node/registrations/template.hbs b/app/guid-node/registrations/template.hbs index 906d3459c..0c8a88c06 100644 --- a/app/guid-node/registrations/template.hbs +++ b/app/guid-node/registrations/template.hbs @@ -121,11 +121,11 @@ @changed={{action this.schemaChanged}} >
- {{schema.name}} + {{schema.localizedName}} {{fa-icon 'info-circle'}} - {{schema.schema.description}} + {{schema.localizedDescription}}
diff --git a/app/guid-node/workflow/-components/flowable-form/array-input/component.ts b/app/guid-node/workflow/-components/flowable-form/array-input/component.ts new file mode 100644 index 000000000..1c261c4cc --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/array-input/component.ts @@ -0,0 +1,119 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import Node from 'ember-osf-web/models/node'; + +import { WorkflowVariable } from '../../../types'; +import { FieldHint } from '../../wizard-form/types'; +import { resolveFlowableType } from '../component'; +import { FieldValueWithType, WorkflowTaskField } from '../types'; + +interface ArrayInputRow { + key: number; + values: Record; +} + +interface ArrayInputArgs { + fields: WorkflowTaskField[]; + value: FieldValueWithType | undefined; + node?: Node; + fieldHints?: Record; + disabled: boolean; + onChange: (valueWithType: FieldValueWithType) => void; +} + +export default class ArrayInput extends Component { + @tracked rows: ArrayInputRow[] = []; + @tracked isInitialized = false; + + private nextKey = 0; + + get rowForms(): Array<{ + row: ArrayInputRow; + label: string; + form: { fields: WorkflowTaskField[] }; + variables: WorkflowVariable[]; + }> { + return this.rows.map((row, index) => ({ + row, + label: `#${index + 1}`, + form: { fields: this.args.fields }, + variables: this.buildVariablesForRow(row), + })); + } + + @action + initialize(): void { + if (this.isInitialized) { + return; + } + this.isInitialized = true; + + const existing = this.args.value; + if (existing && Array.isArray(existing.value)) { + const items = existing.value as Array>; + this.rows = items.map(item => ({ + key: this.allocateKey(), + values: { ...item }, + })); + } + } + + @action + addRow(): void { + this.rows = [ + ...this.rows, + { key: this.allocateKey(), values: {} }, + ]; + this.notifyChange(); + } + + @action + removeRow(key: number): void { + this.rows = this.rows.filter(row => row.key !== key); + this.notifyChange(); + } + + @action + handleRowChange(key: number, variables: WorkflowVariable[]): void { + const row = this.rows.find(r => r.key === key); + if (!row) { + return; + } + const values: Record = {}; + for (const v of variables) { + values[v.name] = v.value; + } + row.values = values; + this.notifyChange(); + } + + private allocateKey(): number { + return this.nextKey++; + } + + private buildVariablesForRow(row: ArrayInputRow): WorkflowVariable[] { + return this.args.fields + .filter(field => !this.isDisplayField(field)) + .map(field => ({ + name: field.id, + value: row.values[field.id] !== undefined ? row.values[field.id] : null, + type: resolveFlowableType(field.type), + })); + } + + private isDisplayField(field: WorkflowTaskField): boolean { + const type = field.type.toLowerCase(); + return ['expression', 'hyperlink', 'link', 'headline', 'headline-with-line', 'spacer', 'horizontal-line'] + .includes(type); + } + + private notifyChange(): void { + const value = this.rows.map(row => row.values); + this.args.onChange({ + value, + type: 'json', + }); + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/array-input/styles.scss b/app/guid-node/workflow/-components/flowable-form/array-input/styles.scss new file mode 100644 index 000000000..aa5fd2515 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/array-input/styles.scss @@ -0,0 +1,33 @@ +.ArrayInput { + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px; + background: #fafafa; +} + +.ArrayInput__row { + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 12px; + margin-bottom: 12px; + background: #fff; +} + +.ArrayInput__rowHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid #eee; +} + +.ArrayInput__rowIndex { + font-weight: 600; + color: #666; + font-size: 0.9em; +} + +.ArrayInput__addButton { + margin-top: 4px; +} diff --git a/app/guid-node/workflow/-components/flowable-form/array-input/template.hbs b/app/guid-node/workflow/-components/flowable-form/array-input/template.hbs new file mode 100644 index 000000000..a3f00a10b --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/array-input/template.hbs @@ -0,0 +1,37 @@ +
+ {{#each this.rowForms as |entry|}} +
+
+ {{entry.label}} + {{#unless @disabled}} + + {{/unless}} +
+ +
+ {{/each}} + + {{#unless @disabled}} + + {{/unless}} +
diff --git a/app/guid-node/workflow/-components/flowable-form/component.ts b/app/guid-node/workflow/-components/flowable-form/component.ts index c2c24fb50..f084be997 100644 --- a/app/guid-node/workflow/-components/flowable-form/component.ts +++ b/app/guid-node/workflow/-components/flowable-form/component.ts @@ -8,14 +8,26 @@ import { WorkflowTaskForm, WorkflowVariable, } from '../../types'; +import { evaluateExpression } from '../wizard-form/expression-evaluator'; +import { FieldHint } from '../wizard-form/types'; import { isValidFieldValue } from './field/component'; import { FieldValueWithType } from './types'; +/** + * Interface for Field components to call back into their parent form. + * Provides setValue() for autofill: always goes through the view layer (View → Model). + */ +export interface FlowableFormContext { + setFieldValue(fieldId: string, valueWithType: FieldValueWithType): void; +} + interface FlowableFormArgs { form: WorkflowTaskForm; variables?: WorkflowVariable[]; node?: Node; - onChange: (variables: WorkflowVariable[], isValid: boolean) => void; + fieldHints?: Record; + fieldContext?: Record; + onChange: (variables: WorkflowVariable[], isValid: boolean, isLoading: boolean) => void; } export function resolveFlowableType(fieldType: string | undefined): string { @@ -38,26 +50,67 @@ export function resolveFlowableType(fieldType: string | undefined): string { return 'string'; } +interface FieldHandle { + setValue(valueWithType: FieldValueWithType): void; +} + export default class FlowableForm extends Component { @tracked fieldValues: Record = {}; @tracked updatedFieldValues: Record = {}; + @tracked loadingFieldIds: Set = new Set(); + + private fieldRegistry = new Map(); + + get formContext(): FlowableFormContext { + return { + setFieldValue: (fieldId: string, valueWithType: FieldValueWithType) => { + this.fieldRegistry.get(fieldId)!.setValue(valueWithType); + }, + }; + } get fields(): WorkflowTaskField[] { return this.args.form.fields || []; } + get visibleFields(): WorkflowTaskField[] { + return this.fields.filter(field => this.isFieldVisible(field)); + } + get hasFields(): boolean { return this.fields.length > 0; } + isFieldVisible(field: WorkflowTaskField): boolean { + const hints = this.args.fieldHints; + if (!hints) { + return true; + } + const hint = hints[field.id]; + if (!hint || hint.visible === undefined || hint.visible === true) { + return true; + } + if (hint.visible === false) { + return false; + } + const ctx = this.args.fieldContext; + if (!ctx) { + return true; + } + return evaluateExpression(hint.visible, ctx); + } + get isValid(): boolean { return this.fields - .filter(field => this.isSubmittableField(field)) + .filter(field => this.isSubmittableField(field) && this.isFieldVisible(field)) .every(field => { + const fieldValue = this.updatedFieldValues[field.id]; + if (fieldValue && fieldValue.valid === false) { + return false; + } if (!field.required) { return true; } - const fieldValue = this.updatedFieldValues[field.id]; const value = fieldValue && fieldValue.value; return isValidFieldValue(field, value); }); @@ -97,6 +150,16 @@ export default class FlowableForm extends Component { this.notifyChange(); } + @action + registerField(fieldId: string, handle: FieldHandle): void { + this.fieldRegistry.set(fieldId, handle); + } + + @action + unregisterField(fieldId: string): void { + this.fieldRegistry.delete(fieldId); + } + @action handleFieldChange(fieldId: string, valueWithType: FieldValueWithType): void { this.updatedFieldValues = { @@ -106,6 +169,18 @@ export default class FlowableForm extends Component { this.notifyChange(); } + @action + handleFieldLoadingChange(fieldId: string, isLoading: boolean): void { + const next = new Set(this.loadingFieldIds); + if (isLoading) { + next.add(fieldId); + } else { + next.delete(fieldId); + } + this.loadingFieldIds = next; + this.notifyChange(); + } + private isSubmittableField(field: WorkflowTaskField): boolean { const type = field.type.toLowerCase(); const displayOnlyTypes = [ @@ -131,6 +206,6 @@ export default class FlowableForm extends Component { }; }); - this.args.onChange(variables, this.isValid); + this.args.onChange(variables, this.isValid, this.loadingFieldIds.size > 0); } } diff --git a/app/guid-node/workflow/-components/flowable-form/export-target/component.ts b/app/guid-node/workflow/-components/flowable-form/export-target/component.ts new file mode 100644 index 000000000..95dbc92bc --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/export-target/component.ts @@ -0,0 +1,59 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency-decorators'; + +import FileProviderModel from 'ember-osf-web/models/file-provider'; +import Node from 'ember-osf-web/models/node'; + +import { FieldValueWithType } from '../types'; + +interface ExportTargetArgs { + node: Node; + value: FieldValueWithType | undefined; + onChange: (valueWithType: FieldValueWithType) => void; + onLoadingChange?: (isLoading: boolean) => void; + disabled: boolean; +} + +export default class ExportTarget extends Component { + @tracked providers: FileProviderModel[] = []; + @tracked selectedProvider: string = ''; + + @task + loadProviders = task(function *(this: ExportTarget) { + const providers: FileProviderModel[] = (yield this.args.node.loadAll('files')).toArray(); + this.providers = providers; + + // Restore from value or pick default + if (this.args.value && this.args.value.type === 'string' && this.args.value.value) { + this.selectedProvider = this.args.value.value as string; + } else if (providers.length > 0) { + const osf = providers.find(p => p.name === 'osfstorage'); + this.selectedProvider = osf ? osf.name : providers[0].name; + this.notifyChange(this.selectedProvider); + } + }); + + @action + initialize() { + if (this.args.onLoadingChange) { this.args.onLoadingChange(true); } + this.loadProviders.perform().finally(() => { + if (this.args.onLoadingChange) { this.args.onLoadingChange(false); } + }); + } + + @action + destinationChanged(event: Event) { + const { value } = (event.target as HTMLSelectElement); + this.selectedProvider = value; + this.notifyChange(value); + } + + private notifyChange(providerName: string): void { + this.args.onChange({ + value: providerName, + type: 'string', + }); + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/export-target/template.hbs b/app/guid-node/workflow/-components/flowable-form/export-target/template.hbs new file mode 100644 index 000000000..f030e5467 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/export-target/template.hbs @@ -0,0 +1,20 @@ +
+ {{#if this.loadProviders.isRunning}} +
+ + {{t 'workflow.console.tasks.dialog.exportTarget.loading'}} +
+ {{else}} + + {{/if}} +
diff --git a/app/guid-node/workflow/-components/flowable-form/field/component.ts b/app/guid-node/workflow/-components/flowable-form/field/component.ts index 2e39bb331..3abff8a4e 100644 --- a/app/guid-node/workflow/-components/flowable-form/field/component.ts +++ b/app/guid-node/workflow/-components/flowable-form/field/component.ts @@ -2,11 +2,27 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import { htmlSafe } from '@ember/template'; +import { fetchSuggestions, SuggestionResult } from 'ember-osf-web/utils/suggestion-api'; + import { WorkflowVariable } from '../../../types'; import { parseProgressSteps, ProgressStep } from '../../progress-sidebar/utils'; -import { resolveFlowableType } from '../component'; +import { evaluateTemplate, hasTemplateDirectives } from '../../wizard-form/template-evaluator'; +import { FieldHint, SuggestionConfig } from '../../wizard-form/types'; +import { FlowableFormContext, resolveFlowableType } from '../component'; import { FieldValueWithType, WorkflowTaskField, WorkflowTaskFieldOption } from '../types'; -import { extractFileMetadata, extractProjectMetadata } from '../utils'; +import { + extractArrayInput, extractExportTarget, extractFileMetadata, extractFileSelector, extractFileUploader, + extractProjectMetadata, +} from '../utils'; + +function renderTemplateAsHtml(tmpl: string, value: Record): ReturnType { + const rendered = tmpl.replace(/\{\{(\w+)\}\}/g, (_: string, field: string) => { + const v = value[field]; + return v != null ? String(v) : ''; + }); + return htmlSafe(rendered); +} function getOptionValue(option: WorkflowTaskFieldOption): string | undefined { return (option.id !== undefined && option.id !== null) ? option.id : option.name; @@ -62,11 +78,123 @@ interface TaskFormFieldArgs { fieldValues: Record; variables: WorkflowVariable[]; node?: any; + fieldHints?: Record; + formContext?: FlowableFormContext; + fieldContext?: Record; onChange: (fieldId: string, valueWithType: FieldValueWithType) => void; + onLoadingChange?: (fieldId: string, isLoading: boolean) => void; + onRegister?: (fieldId: string, handle: { setValue(v: FieldValueWithType): void }) => void; + onUnregister?: (fieldId: string) => void; } export default class TaskFormField extends Component { @tracked updatedValue: FieldValueWithType | null = null; + @tracked suggestionResults: SuggestionResult[] = []; + @tracked showSuggestions = false; + + private searchTimer: ReturnType | null = null; + + // --- Field registry (for autofill via view layer) --- + + setValue(valueWithType: FieldValueWithType): void { + this.updatedValue = valueWithType; + this.args.onChange(this.args.field.id, valueWithType); + } + + @action + onFieldInsert(): void { + if (this.args.onRegister) { this.args.onRegister(this.args.field.id, this); } + } + + @action + onFieldDestroy(): void { + if (this.args.onUnregister) { this.args.onUnregister(this.args.field.id); } + } + + // --- Hint accessors --- + + get hint(): FieldHint | undefined { + return this.args.fieldHints && this.args.fieldHints[this.args.field.id]; + } + + get suggestionConfigs(): SuggestionConfig[] | null { + const configs = this.hint && this.hint.suggestion; + if (!configs || !configs.length) { + return null; + } + return configs.filter((s: SuggestionConfig) => Boolean(s.template)); + } + + get hasSuggestion(): boolean { + return this.suggestionConfigs !== null && this.suggestionConfigs.length > 0; + } + + get widthStyle(): string | undefined { + const width = this.hint && this.hint.ui && this.hint.ui.width; + if (width === 'narrow') { + return 'max-width: 25%;'; + } + if (width === 'half') { + return 'max-width: 50%;'; + } + return undefined; + } + + get isFreetext(): boolean { + return Boolean(this.hint && this.hint.ui && this.hint.ui.freetext); + } + + // --- Suggestion / typeahead --- + + get suggestionItems(): Array<{ key: string; html: ReturnType }> { + const configs = this.suggestionConfigs!; + return this.suggestionResults.map((result, idx) => { + const config = configs.find(c => c.key === result.key)!; + return { + key: String(idx), + html: renderTemplateAsHtml(config.template!, result.value), + }; + }); + } + + @action + onTypeaheadInput(event: Event): void { + const text = (event.target as HTMLInputElement).value; + this.setValue({ + value: text === '' ? null : text, + type: 'string', + }); + if (this.searchTimer) { + clearTimeout(this.searchTimer); + } + this.searchTimer = setTimeout(() => this.doSearch(text), 300); + } + + @action + onTypeaheadBlur(): void { + setTimeout(() => { this.showSuggestions = false; }, 200); + } + + @action + onSuggestionSelect(key: string): void { + const idx = parseInt(key, 10); + const result = this.suggestionResults[idx]; + const configs = this.suggestionConfigs!; + const config = configs.find(c => c.key === result.key)!; + + const valueField = config.valueField || config.key.split(':')[1]; + this.setValue({ + value: String(result.value[valueField]), + type: 'string', + }); + this.showSuggestions = false; + + if (config.autofill) { + this.applyAutofill(config.autofill, result.value); + } + } + + // --- Standard field handlers --- @action handleChange(event: Event): void { @@ -118,17 +246,58 @@ export default class TaskFormField extends Component { this.args.onChange(this.args.field.id, valueWithType); } + @action + handleFileSelectorChange(valueWithType: FieldValueWithType): void { + this.updatedValue = valueWithType; + this.args.onChange(this.args.field.id, valueWithType); + } + + @action + handleFileUploaderChange(valueWithType: FieldValueWithType): void { + this.updatedValue = valueWithType; + this.args.onChange(this.args.field.id, valueWithType); + } + + @action + handleExportTargetChange(valueWithType: FieldValueWithType): void { + this.updatedValue = valueWithType; + this.args.onChange(this.args.field.id, valueWithType); + } + + @action + handleLoadingChange(isLoading: boolean): void { + if (this.args.onLoadingChange) { this.args.onLoadingChange(this.args.field.id, isLoading); } + } + + @action + handleArrayInputChange(valueWithType: FieldValueWithType): void { + this.updatedValue = valueWithType; + this.args.onChange(this.args.field.id, valueWithType); + } + get displayValue(): unknown { - return this.updatedValue !== null ? this.updatedValue.value : this.currentValue; + if (this.updatedValue !== null) { + return this.updatedValue.value; + } + const current = this.currentValue; + return current ? current.value : null; } - get hasError(): boolean { + get hasRequiredError(): boolean { if (!this.isRequired) { return false; } const val = this.displayValue; - const isValid = isValidFieldValue(this.args.field, val); - return !isValid; + return !isValidFieldValue(this.args.field, val); + } + + get hasCustomError(): boolean { + const current = this.updatedValue || this.currentValue; + return current !== undefined && current !== null && current.valid === false; + } + + get hasError(): boolean { + return this.hasRequiredError || this.hasCustomError; } get type(): string { return this.args.field.type; @@ -159,6 +328,9 @@ export default class TaskFormField extends Component { } get stringValue(): string { + if (this.updatedValue !== null) { + return toStringValue(this.updatedValue); + } const current = this.currentValue; if (!current) { return ''; @@ -167,6 +339,9 @@ export default class TaskFormField extends Component { } get booleanValue(): boolean { + if (this.updatedValue !== null) { + return toBooleanValue(this.updatedValue); + } const current = this.currentValue; if (!current) { return false; @@ -198,12 +373,60 @@ export default class TaskFormField extends Component { } get isTextarea(): boolean { - if (this.isProjectMetadataSelector) { + if (this.isProjectMetadataSelector || this.isArrayInput) { + return false; + } + if (this.isFileSelector || this.isFileUploader || this.isExportTarget) { return false; } return this.type === 'multi-line-text' || this.type === 'textarea'; } + get isFileSelector(): boolean { + return extractFileSelector(this.args.field); + } + + get fileUploaderPlaceholder() { + return extractFileUploader(this.args.field); + } + + get isFileUploader(): boolean { + return this.fileUploaderPlaceholder !== null; + } + + get isExportTarget(): boolean { + return extractExportTarget(this.args.field); + } + + get arrayInputPlaceholder() { + return extractArrayInput(this.args.field); + } + + get isArrayInput(): boolean { + return this.arrayInputPlaceholder !== null; + } + + get arrayInputFields() { + return this.arrayInputPlaceholder ? this.arrayInputPlaceholder.fields : []; + } + + get arrayFieldHints(): Record | undefined { + const allHints = this.args.fieldHints; + if (!allHints) { + return undefined; + } + const prefix = `${this.args.field.id}.`; + const subHints: Record = {}; + let found = false; + for (const [key, hint] of Object.entries(allHints)) { + if (key.startsWith(prefix)) { + subHints[key.substring(prefix.length)] = hint; + found = true; + } + } + return found ? subHints : undefined; + } + get projectMetadataPlaceholder() { return extractProjectMetadata(this.args.field); } @@ -299,7 +522,8 @@ export default class TaskFormField extends Component { const field = this.args.field as unknown as { expression?: string }; const expression = field.expression || ''; - return expression.replace(/\$\{([^}]+)\}/g, (_match, varName) => { // tslint:disable-line:variable-name + // Step 1: ${...} (Flowable UEL) resolution + const uelResolved = expression.replace(/\$\{([^}]+)\}/g, (_, varName) => { const trimmed = varName.trim(); const variable = this.args.variables.find(v => v.name === trimmed); if (variable) { @@ -311,6 +535,18 @@ export default class TaskFormField extends Component { } return ''; }); + + // Step 2: {{ }} / {% %} template directives (client-side) + if (!hasTemplateDirectives(uelResolved)) { + return uelResolved; + } + // Build context from variables (tracked via currentPageVariables) + // rather than fieldContext (not tracked by Glimmer). + const ctx: Record = {}; + for (const v of this.args.variables) { + ctx[v.name] = v.value; + } + return evaluateTemplate(uelResolved, ctx); } get hyperlinkUrl(): string { @@ -329,4 +565,34 @@ export default class TaskFormField extends Component { get remainingExpressionText(): string { return this.parsedExpression.remainingText; } + + private applyAutofill(autofillMap: Record, responseValue: Record): void { + const formContext = this.args.formContext!; + const allHints = this.args.fieldHints || {}; + + for (const [targetFieldId, responseField] of Object.entries(autofillMap)) { + const rawValue = responseValue[responseField]; + if (rawValue == null) { + continue; + } + let resolved = String(rawValue); + const targetHint = allHints[targetFieldId]; + const targetOptionMap = targetHint && targetHint.ui && targetHint.ui.optionMap; + if (targetOptionMap && targetOptionMap[resolved]) { + resolved = targetOptionMap[resolved]; + } + formContext.setFieldValue(targetFieldId, { value: resolved, type: 'string' }); + } + } + + private async doSearch(keyword: string): Promise { + const { node } = this.args; + if (!node) { + return; + } + const keys = this.suggestionConfigs!.map(c => c.key); + const results = await fetchSuggestions(node.id, keys, keyword); + this.suggestionResults = results; + this.showSuggestions = results.length > 0; + } } diff --git a/app/guid-node/workflow/-components/flowable-form/field/styles.scss b/app/guid-node/workflow/-components/flowable-form/field/styles.scss new file mode 100644 index 000000000..a40c96cb9 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/field/styles.scss @@ -0,0 +1,28 @@ +.typeahead-container { + position: relative; +} + +.typeahead-dropdown { + position: absolute; + z-index: 1000; + width: 100%; + max-height: 300px; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; + background: #fff; + border: 1px solid #ccc; + border-top: 0; + border-radius: 0 0 4px 4px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.typeahead-item { + padding: 6px 10px; + cursor: pointer; + + &:hover { + background-color: #f5f5f5; + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/field/template.hbs b/app/guid-node/workflow/-components/flowable-form/field/template.hbs index bce990322..4888b68df 100644 --- a/app/guid-node/workflow/-components/flowable-form/field/template.hbs +++ b/app/guid-node/workflow/-components/flowable-form/field/template.hbs @@ -1,4 +1,10 @@ -
+
{{#if this.isExpression}} {{#if this.hasProgressSteps}} {{#if this.remainingExpressionText}} @@ -45,22 +51,40 @@
{{else if this.isSelect}} - + {{#each this.optionsWithValue as |item|}} - + + {{else}} + + {{/if}} {{else if this.isRadio}} {{#each this.optionsWithValue as |item|}}
@@ -118,6 +142,7 @@ @multiSelect={{this.projectMetadataMultiSelect}} @value={{this.currentValue}} @onChange={{this.handleProjectMetadataSelection}} + @onLoadingChange={{this.handleLoadingChange}} @disabled={{this.isReadOnly}} /> {{else if this.isFileMetadataSelector}} @@ -127,6 +152,43 @@ @multiSelect={{this.fileMetadataMultiSelect}} @value={{this.currentValue}} @onChange={{this.handleFileMetadataSelection}} + @onLoadingChange={{this.handleLoadingChange}} + @disabled={{this.isReadOnly}} + /> + {{else if this.isFileSelector}} + + {{else if this.isFileUploader}} + + {{else if this.isExportTarget}} + + {{else if this.isArrayInput}} + {{else if this.isTextarea}} @@ -148,6 +210,31 @@ required={{this.isRequired}} {{on "change" this.handleChange}} /> + {{else if this.hasSuggestion}} +
+ + {{#if this.showSuggestions}} +
    + {{#each this.suggestionItems as |item|}} +
  • + {{{item.html}}} +
  • + {{/each}} +
+ {{/if}} +
{{else}} {{/if}} - {{#if this.hasError}} + {{#if this.hasRequiredError}} {{t 'workflow.console.tasks.dialog.fieldRequired'}} {{/if}} {{/if}} diff --git a/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/component.ts b/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/component.ts index 6a7f944c9..7c194b4f0 100644 --- a/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/component.ts +++ b/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/component.ts @@ -39,7 +39,6 @@ interface FileMetadataEntry { lastPartDepth: number; folder: boolean; title: string | null; - manager: string | null; url: string; style: string; visible: boolean; @@ -53,6 +52,7 @@ interface FileMetadataSelectorArgs { multiSelect: boolean; value: FieldValueWithType | undefined; onChange: (valueWithType: FieldValueWithType) => void; + onLoadingChange?: (isLoading: boolean) => void; disabled: boolean; } @@ -132,14 +132,12 @@ export default class FileMetadataSelector extends Component { const item = entry.items.find(it => it.schema === this.schemaId); if (item) { const titleJa = item.data['grdm-file:title-ja']; const titleEn = item.data['grdm-file:title-en']; - const managerJa = item.data['grdm-file:data-man-name-ja']; - const managerEn = item.data['grdm-file:data-man-name-en']; let title = null; if (titleJa && titleJa.value) { @@ -148,14 +146,7 @@ export default class FileMetadataSelector extends Component { + if (this.args.onLoadingChange) { this.args.onLoadingChange(false); } + }); } } diff --git a/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/template.hbs b/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/template.hbs index 4aec2dc85..56c2727a6 100644 --- a/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/template.hbs +++ b/app/guid-node/workflow/-components/flowable-form/file-metadata-selector/template.hbs @@ -85,9 +85,6 @@
{{t 'workflow.console.tasks.dialog.fileMetadataSelector.path'}}: {{entry.path}} - {{#if entry.manager}} - | {{t 'workflow.console.tasks.dialog.fileMetadataSelector.manager'}}: {{entry.manager}} - {{/if}}
{{/unless}} diff --git a/app/guid-node/workflow/-components/flowable-form/file-selector/component.ts b/app/guid-node/workflow/-components/flowable-form/file-selector/component.ts new file mode 100644 index 000000000..d642e7b94 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/file-selector/component.ts @@ -0,0 +1,73 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency-decorators'; + +import FileProviderModel from 'ember-osf-web/models/file-provider'; +import Node from 'ember-osf-web/models/node'; + +import { FieldValueWithType } from '../types'; +import NotifyingSelectionManager from './notifying-selection-manager'; + +interface FileSelectorValue { + provider: string; + files: Array<{ materialized: string; enable: boolean }>; +} + +interface FileSelectorArgs { + node: Node; + value: FieldValueWithType | undefined; + onChange: (valueWithType: FieldValueWithType) => void; + onLoadingChange?: (isLoading: boolean) => void; + disabled: boolean; +} + +export default class FileSelector extends Component { + selectionManager: NotifyingSelectionManager; + @tracked providerName: string = ''; + + @task + loadProvider = task(function *(this: FileSelector) { + const providers: FileProviderModel[] = (yield this.args.node.loadAll('files')).toArray(); + const osf = providers.find((p: FileProviderModel) => p.name === 'osfstorage'); + if (osf) { + this.providerName = osf.name; + } else { + const inst = providers.filter((p: FileProviderModel) => p.forInstitutions); + inst.sort((a: FileProviderModel, b: FileProviderModel) => a.name.localeCompare(b.name)); + this.providerName = inst.length > 0 ? inst[0].name : providers[0].name; + } + this.emitValue(); + }); + + constructor(owner: unknown, args: FileSelectorArgs) { + super(owner, args); + this.selectionManager = new NotifyingSelectionManager(() => this.emitValue()); + } + + @action + initialize() { + if (this.args.onLoadingChange) { this.args.onLoadingChange(true); } + this.loadProvider.perform().finally(() => { + if (this.args.onLoadingChange) { this.args.onLoadingChange(false); } + }); + } + + private emitValue(): void { + if (!this.providerName) { + return; + } + const files = Object.keys(this.selectionManager.checked).map(materialized => ({ + materialized, + enable: this.selectionManager.checked[materialized], + })); + const value: FileSelectorValue = { + provider: this.providerName, + files, + }; + this.args.onChange({ + value, + type: 'json', + }); + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/file-selector/notifying-selection-manager.ts b/app/guid-node/workflow/-components/flowable-form/file-selector/notifying-selection-manager.ts new file mode 100644 index 000000000..08bfb789d --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/file-selector/notifying-selection-manager.ts @@ -0,0 +1,16 @@ +import { SelectionManager } from 'ember-osf-web/guid-node/package/selection'; +import { WaterButlerFile } from 'ember-osf-web/utils/waterbutler/base'; + +export default class NotifyingSelectionManager extends SelectionManager { + private onUpdate: () => void; + + constructor(onUpdate: () => void) { + super(); + this.onUpdate = onUpdate; + } + + setChecked(item: WaterButlerFile, value: boolean) { + super.setChecked(item, value); + this.onUpdate(); + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/file-selector/template.hbs b/app/guid-node/workflow/-components/flowable-form/file-selector/template.hbs new file mode 100644 index 000000000..428a29275 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/file-selector/template.hbs @@ -0,0 +1,12 @@ +
+ + + +
diff --git a/app/guid-node/workflow/-components/flowable-form/file-uploader/component.ts b/app/guid-node/workflow/-components/flowable-form/file-uploader/component.ts new file mode 100644 index 000000000..497a2a738 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/file-uploader/component.ts @@ -0,0 +1,225 @@ +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { all } from 'ember-concurrency'; +import { task } from 'ember-concurrency-decorators'; +import DS from 'ember-data'; +import Toast from 'ember-toastr/services/toast'; +import $ from 'jquery'; + +import File from 'ember-osf-web/models/file'; +import FileProviderModel from 'ember-osf-web/models/file-provider'; +import Node from 'ember-osf-web/models/node'; +import CurrentUser from 'ember-osf-web/services/current-user'; + +import { FieldValueWithType } from '../types'; + +interface FileUploaderArgs { + node: Node; + fieldId: string; + path: string; + acceptExtensions: string[]; + value: FieldValueWithType | undefined; + onChange: (valueWithType: FieldValueWithType) => void; + onLoadingChange?: (isLoading: boolean) => void; + disabled: boolean; +} + +export default class FileUploader extends Component { + @service currentUser!: CurrentUser; + @service store!: DS.Store; + @service toast!: Toast; + + @tracked allFiles: File[] = []; + @tracked targetDirectory: File | null = null; + @tracked loading = true; + @tracked filter = ''; + @tracked sort = 'name'; + + dropzoneOptions = { + createImageThumbnails: false, + method: 'PUT', + withCredentials: true, + preventMultipleFiles: false, + acceptDirectories: false, + timeout: 1000 * 60 * 60 * 48, + maxFilesize: null, + }; + + get dropZoneId(): string { + return `file-uploader-${this.args.fieldId}`; + } + + get files(): File[] { + let results = this.allFiles; + if (this.filter) { + const filterLower = this.filter.toLowerCase(); + results = results.filter(f => f.name.toLowerCase().includes(filterLower)); + } + if (this.sort) { + const reverse = this.sort.startsWith('-'); + const key = reverse ? this.sort.slice(1) : this.sort; + results = results.slice().sort((a, b) => { + const av = (a as any)[key] || ''; + const bv = (b as any)[key] || ''; + return reverse ? String(bv).localeCompare(String(av)) : String(av).localeCompare(String(bv)); + }); + } + return results; + } + + get hasRejectedFiles(): boolean { + if (this.args.acceptExtensions.length === 0) { + return false; + } + return this.allFiles.some(f => !this.isAcceptedExtension(f.name)); + } + + get rejectedFileNames(): string { + if (this.args.acceptExtensions.length === 0) { + return ''; + } + return this.allFiles + .filter(f => !this.isAcceptedExtension(f.name)) + .map(f => f.name) + .join(', '); + } + + // --- Folder setup --- + + @task + setupFolder = task(function *(this: FileUploader) { + const providers: FileProviderModel[] = yield this.args.node.get('files'); + const osf = providers.find((p: FileProviderModel) => p.name === 'osfstorage'); + if (!osf) { + throw new Error('osfstorage provider not found'); + } + + let currentDir: File = yield osf.get('rootFolder'); + const segments = this.args.path.split('/').filter(s => s.length > 0); + + for (const segment of segments) { + let children: File[] = yield currentDir.loadAll('files'); + let found = children.find(f => f.name === segment); + if (!found) { + const newFolderUrl = currentDir.get('links').new_folder; + yield this.currentUser.authenticatedAJAX({ + url: `${newFolderUrl}&name=${encodeURIComponent(segment)}`, + type: 'PUT', + }); + children = yield currentDir.loadAll('files'); + found = children.find(f => f.name === segment); + } + currentDir = found!; + } + + this.targetDirectory = currentDir; + const fileList: File[] = yield currentDir.loadAll('files'); + this.allFiles = fileList; + this.loading = false; + this.emitValue(); + }); + + // --- File operations --- + + @task + addFile = task(function *(this: FileUploader, id: string) { + const duplicate = this.allFiles.find(f => f.id === id); + const file: File = yield this.store.findRecord( + 'file', id, + duplicate ? {} : { adapterOptions: { query: { create_guid: 1 } } }, + ); + if (duplicate) { + this.allFiles = this.allFiles.filter(f => f.id !== id); + } + this.allFiles = [...this.allFiles, file]; + this.emitValue(); + }); + + @task + deleteFiles = task(function *(this: FileUploader, files: File[]) { + yield all(files.map(async (file: File) => { + await file.destroyRecord(); + })); + const deletedIds = new Set(files.map(f => f.id)); + this.allFiles = this.allFiles.filter(f => !deletedIds.has(f.id)); + this.emitValue(); + }); + + @task + renameFile = task(function *( + this: FileUploader, + file: File, + name: string, + conflict?: string, + conflictingFile?: File, + ) { + yield file.rename(name, conflict); + if (conflictingFile) { + this.allFiles = this.allFiles.filter(f => f.id !== conflictingFile.id); + } + this.emitValue(); + }); + + // --- Lifecycle --- + + @action + initialize() { + if (this.args.onLoadingChange) { this.args.onLoadingChange(true); } + this.setupFolder.perform().finally(() => { + if (this.args.onLoadingChange) { this.args.onLoadingChange(false); } + }); + } + + @action + buildUrl(files: File[]): string | undefined { + if (!this.targetDirectory) { + return undefined; + } + return `${this.targetDirectory.links.upload}?${$.param({ name: files[0].name })}`; + } + + @action + openFile(): void { + // no-op: file viewing not needed in workflow uploader + } + + @action + moveFile(): void { + // no-op: file moving handled by ScriptTask + } + + @action + handleUpdateFilter(filter: string): void { + this.filter = filter; + } + + private isAcceptedExtension(filename: string): boolean { + if (this.args.acceptExtensions.length === 0) { + return true; + } + const pos = filename.lastIndexOf('.'); + if (pos <= 0) { + return false; + } + const ext = filename.substring(pos).toLowerCase(); + return this.args.acceptExtensions.includes(ext); + } + + private emitValue(): void { + const files = this.allFiles.map(f => ({ + path: f.path, + materialized: f.get('materializedPath') || `/${f.name}`, + enable: true, + })); + const result: { value: unknown; type: string; valid?: boolean } = { + value: { provider: 'osfstorage', files }, + type: 'json', + }; + if (this.hasRejectedFiles) { + result.valid = false; + } + this.args.onChange(result); + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/file-uploader/styles.scss b/app/guid-node/workflow/-components/flowable-form/file-uploader/styles.scss new file mode 100644 index 000000000..90a7ed0d6 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/file-uploader/styles.scss @@ -0,0 +1,7 @@ +.file-uploader { + :global([data-test-share-dialog-button]), + :global([data-test-view-button]), + :global([data-test-move-button]) { + display: none; + } +} diff --git a/app/guid-node/workflow/-components/flowable-form/file-uploader/template.hbs b/app/guid-node/workflow/-components/flowable-form/file-uploader/template.hbs new file mode 100644 index 000000000..94cc1c5d3 --- /dev/null +++ b/app/guid-node/workflow/-components/flowable-form/file-uploader/template.hbs @@ -0,0 +1,29 @@ +
+ {{#if this.loading}} +
+ +
+ {{else}} + + {{#if this.hasRejectedFiles}} +
+ Extension not allowed: {{this.rejectedFileNames}} +
+ {{/if}} + {{/if}} +
diff --git a/app/guid-node/workflow/-components/flowable-form/project-metadata-selector/component.ts b/app/guid-node/workflow/-components/flowable-form/project-metadata-selector/component.ts index 726cc626a..f9c684f83 100644 --- a/app/guid-node/workflow/-components/flowable-form/project-metadata-selector/component.ts +++ b/app/guid-node/workflow/-components/flowable-form/project-metadata-selector/component.ts @@ -37,6 +37,7 @@ interface ProjectMetadataSelectorArgs { multiSelect: boolean; value: FieldValueWithType | undefined; onChange: (valueWithType: FieldValueWithType) => void; + onLoadingChange?: (isLoading: boolean) => void; disabled: boolean; } @@ -113,7 +114,10 @@ export default class ProjectMetadataSelector extends Component { + if (this.args.onLoadingChange) { this.args.onLoadingChange(false); } + }); } } @@ -122,21 +126,15 @@ export default class ProjectMetadataSelector extends Component 0) { - return; - } - const guids = this.extractGuidsFromValue(this.args.value); - if (guids.length > 0) { + if (this.selectedGuids.length === 0 && guids.length > 0) { this.selectedGuids = guids; - this.notifyRecordsSelected(guids); - } - } else if (!this.selectedGuid) { - const guid = this.extractGuidsFromValue(this.args.value)[0]; - if (guid) { - this.selectedGuid = guid; - this.notifyRecordSelected(guid); } + } else if (!this.selectedGuid && guids[0]) { + [this.selectedGuid] = guids; } } diff --git a/app/guid-node/workflow/-components/flowable-form/template.hbs b/app/guid-node/workflow/-components/flowable-form/template.hbs index 29d62cc00..c4e9e4a86 100644 --- a/app/guid-node/workflow/-components/flowable-form/template.hbs +++ b/app/guid-node/workflow/-components/flowable-form/template.hbs @@ -1,13 +1,19 @@ {{#if this.hasFields}} - {{#each this.fields as |field|}} + {{#each this.visibleFields as |field|}} {{/each}} {{/if}} diff --git a/app/guid-node/workflow/-components/flowable-form/types.ts b/app/guid-node/workflow/-components/flowable-form/types.ts index c9f5f57c7..2dff03b8e 100644 --- a/app/guid-node/workflow/-components/flowable-form/types.ts +++ b/app/guid-node/workflow/-components/flowable-form/types.ts @@ -25,6 +25,7 @@ interface WorkflowTaskField { interface FieldValueWithType { value: unknown; type: string; + valid?: boolean; } export { diff --git a/app/guid-node/workflow/-components/flowable-form/utils.ts b/app/guid-node/workflow/-components/flowable-form/utils.ts index e9c206591..e02091a13 100644 --- a/app/guid-node/workflow/-components/flowable-form/utils.ts +++ b/app/guid-node/workflow/-components/flowable-form/utils.ts @@ -45,3 +45,69 @@ export function extractProjectMetadata(field: WorkflowTaskField): ProjectMetadat export function extractFileMetadata(field: WorkflowTaskField): FileMetadataPlaceholder | null { return extractMetadataPlaceholder(field, '_FILE_METADATA'); } + +export function extractFileSelector(field: WorkflowTaskField): boolean { + if (field.type !== 'multi-line-text') { + return false; + } + return field.placeholder === '_FILE_SELECTOR()'; +} + +export function extractExportTarget(field: WorkflowTaskField): boolean { + if (field.type !== 'multi-line-text') { + return false; + } + return field.placeholder === '_EXPORT_TARGET()'; +} + +export interface ArrayInputPlaceholder { + fields: WorkflowTaskField[]; +} + +export interface FileUploaderPlaceholder { + path: string; + acceptExtensions: string[]; +} + +export function extractFileUploader(field: WorkflowTaskField): FileUploaderPlaceholder | null { + if (field.type !== 'multi-line-text') { + return null; + } + const { placeholder } = field; + if (!placeholder) { + return null; + } + const match = placeholder.match(/^_FILE_UPLOADER\((.+)\)$/); + if (!match) { + return null; + } + const raw = match[1]; + const segments = raw.split(',').map(s => s.trim()); + const extStart = segments.findIndex(s => s.startsWith('.')); + let path: string; + let acceptExtensions: string[]; + if (extStart === -1) { + path = raw.trim(); + acceptExtensions = []; + } else { + path = segments.slice(0, extStart).join(',').trim(); + acceptExtensions = segments.slice(extStart).map(e => e.toLowerCase()); + } + return { path, acceptExtensions }; +} + +export function extractArrayInput(field: WorkflowTaskField): ArrayInputPlaceholder | null { + if (field.type !== 'multi-line-text') { + return null; + } + const { placeholder } = field; + if (!placeholder) { + return null; + } + const match = placeholder.match(/^_ARRAY_INPUT\((.+)\)$/s); + if (!match) { + return null; + } + const fields: WorkflowTaskField[] = JSON.parse(match[1]); + return { fields }; +} diff --git a/app/guid-node/workflow/-components/progress-sidebar/component.ts b/app/guid-node/workflow/-components/progress-sidebar/component.ts index 356749727..b56532925 100644 --- a/app/guid-node/workflow/-components/progress-sidebar/component.ts +++ b/app/guid-node/workflow/-components/progress-sidebar/component.ts @@ -12,6 +12,7 @@ import { interface ProgressSidebarArgs { form?: WorkflowTaskForm; variables?: WorkflowVariable[]; + wizardSteps?: ProgressStep[]; } export default class ProgressSidebar extends Component { @@ -26,10 +27,16 @@ export default class ProgressSidebar extends Component { } get hasProgressSteps(): boolean { + if (this.args.wizardSteps) { + return this.args.wizardSteps.length > 0; + } return this.parsedExpression.steps.length > 0; } get progressSteps(): ProgressStep[] { + if (this.args.wizardSteps) { + return this.args.wizardSteps; + } return this.parsedExpression.steps; } } diff --git a/app/guid-node/workflow/-components/task-dialog/component.ts b/app/guid-node/workflow/-components/task-dialog/component.ts index 699f90be1..aeaf59a37 100644 --- a/app/guid-node/workflow/-components/task-dialog/component.ts +++ b/app/guid-node/workflow/-components/task-dialog/component.ts @@ -12,6 +12,7 @@ import { WorkflowVariable, } from '../../types'; import { isFinalStep } from '../progress-sidebar/utils'; +import { extractWizardConfig, WizardNavigation } from '../wizard-form/types'; interface WorkflowTaskDialogArgs { open: boolean; @@ -29,6 +30,7 @@ export default class WorkflowTaskDialog extends Component +