+ >
{{#if this.showSuggestions}}
{{#each this.suggestionItems as |item|}}
+ {{!-- template-lint-disable no-invalid-interactive --}}
-
+ {{!-- template-lint-disable no-triple-curlies --}}
{{{item.html}}}
{{/each}}
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts
index 664d0899e..b9b9dc1f8 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts
@@ -25,6 +25,8 @@ export default class LabelContent extends Component {
// Private property
shouldShowExample = false;
+ circleMarker = '\u25CB';
+ infoMark = '\u24D8';
@computed('inputBlockUI')
get displayTextOverride(): string | undefined {
@@ -49,7 +51,6 @@ export default class LabelContent extends Component {
return undefined;
}
if (!this.tagDefs) {
- console.warn(`[metadata] tagDefs not provided for block with ui.item.tags: ${this.schemaBlock.displayText}`);
return undefined;
}
return resolveTags(tags, this.tagDefs, text => this.getLocalizedText(text));
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs
index c45fe8217..5490b81cd 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs
@@ -1,8 +1,8 @@
{{assert 'Registries::SchemaBlockRenderer::Label::LabelContent requires a @schemaBlock' @schemaBlock}}
- {{~#if (eq this.itemMarker "circle")~}}◯ {{~/if~}}
+ local-class='DisplayText {{if this.displayTextOverride 'SubLabel'}}'>
+ {{~#if (eq this.itemMarker 'circle')~}}{{this.circleMarker}} {{~/if~}}
{{~this.localizedDisplayText~}}
{{~#if (and @isRequired (not @readonly))~}}
*
@@ -20,7 +20,7 @@
{{~/each~}}
{{~#if this.itemInfo~}}
- ⓘ
+ {{this.infoMark}}
{{this.itemInfo}}
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/styles.scss b/lib/osf-components/addon/components/registries/schema-block-renderer/styles.scss
index a390a48f2..3057f4af8 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/styles.scss
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/styles.scss
@@ -9,4 +9,4 @@
.Element {
margin: 10px 0 0;
-}
\ No newline at end of file
+}
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/template.hbs b/lib/osf-components/addon/components/registries/schema-block-renderer/template.hbs
index 77c2c12bc..838c5bc47 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/template.hbs
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/template.hbs
@@ -2,11 +2,11 @@
{{#let (get renderers @schemaBlock.blockType) as |Renderer|}}
{{#if this.isLabelBlock}}
-
+
{{else}}
-
-
-
+
+
+
{{/if}}
{{/let}}
diff --git a/lib/osf-components/addon/components/registries/ui-visual-items/component.ts b/lib/osf-components/addon/components/registries/ui-visual-items/component.ts
index 9706587e0..a0af2b784 100644
--- a/lib/osf-components/addon/components/registries/ui-visual-items/component.ts
+++ b/lib/osf-components/addon/components/registries/ui-visual-items/component.ts
@@ -16,6 +16,8 @@ export default class UiVisualItems extends Component {
tagDefs?: TagDefs;
changeset?: any;
isTopLevel: boolean = false;
+ circleMarker = '\u25CB';
+ infoMark = '\u24D8';
@action
preventLabelFocus(event: Event) {
diff --git a/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs b/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs
index df6a088f2..3a8472b65 100644
--- a/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs
+++ b/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs
@@ -3,7 +3,7 @@
{{#if item.uiGroup.localizedTitle}}
-
{{#if (eq item.uiGroup.marker "circle")}}◯ {{/if}}{{item.uiGroup.localizedTitle}}
+
{{#if (eq item.uiGroup.marker 'circle')}}{{this.circleMarker}} {{/if}}{{item.uiGroup.localizedTitle}}
{{#each item.uiGroup.resolvedTags as |tag|}}
{{tag.localizedText}}
@@ -16,7 +16,7 @@
{{/each}}
{{#if item.uiGroup.localizedInfo}}
- ⓘ
+ {{this.infoMark}}
{{item.uiGroup.localizedInfo}}
diff --git a/lib/osf-components/addon/components/registries/value-check-mark/component.ts b/lib/osf-components/addon/components/registries/value-check-mark/component.ts
index b96ba22da..4fa766112 100644
--- a/lib/osf-components/addon/components/registries/value-check-mark/component.ts
+++ b/lib/osf-components/addon/components/registries/value-check-mark/component.ts
@@ -15,9 +15,9 @@ export default class ValueCheckMark extends Component {
return false;
}
if (Array.isArray(value)) {
- return value.some((row: any) =>
- Object.values(row).some((v: any) => v !== null && v !== undefined && v !== ''),
- );
+ return value.some((row: any) => Object.values(row).some(
+ (v: any) => v !== null && v !== undefined && v !== '',
+ ));
}
return true;
});
diff --git a/lib/registries/addon/drafts/draft/-components/register/component.ts b/lib/registries/addon/drafts/draft/-components/register/component.ts
index 803d8bf73..570c2f55e 100644
--- a/lib/registries/addon/drafts/draft/-components/register/component.ts
+++ b/lib/registries/addon/drafts/draft/-components/register/component.ts
@@ -85,7 +85,7 @@ export default class Register extends Component.extend({
@computed('draftRegistration.registrationResponses')
get wekoItemId(): string | null {
- return getWekoItemId(this.draftRegistration?.registrationResponses);
+ return getWekoItemId(this.draftRegistration && this.draftRegistration.registrationResponses);
}
@computed('draftRegistration.registrationSchema.name')
From a7a434a24b4c01733f122eaa7e797da98bbf0b3c Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Tue, 31 Mar 2026 21:47:20 +0900
Subject: [PATCH 07/14] Add workflow unit tests and fix acceptance test stub
---
tests/acceptance/guid-node/workflow-test.ts | 4 +
.../workflow/expression-evaluator-test.ts | 114 ++++++++
.../workflow/template-evaluator-test.ts | 258 ++++++++++++++++++
3 files changed, 376 insertions(+)
create mode 100644 tests/unit/guid-node/workflow/expression-evaluator-test.ts
create mode 100644 tests/unit/guid-node/workflow/template-evaluator-test.ts
diff --git a/tests/acceptance/guid-node/workflow-test.ts b/tests/acceptance/guid-node/workflow-test.ts
index dff3af131..b560280b2 100644
--- a/tests/acceptance/guid-node/workflow-test.ts
+++ b/tests/acceptance/guid-node/workflow-test.ts
@@ -53,6 +53,10 @@ function stubWorkflowRequests(
return Promise.resolve({ data: config.tasks || [] });
}
+ if (url.includes('/workflow/templates/') && method === 'GET') {
+ return Promise.resolve({ data: [] });
+ }
+
if (url.includes('/workflow/templates/') && method === 'POST') {
return Promise.resolve({
data: {
diff --git a/tests/unit/guid-node/workflow/expression-evaluator-test.ts b/tests/unit/guid-node/workflow/expression-evaluator-test.ts
new file mode 100644
index 000000000..1530f542a
--- /dev/null
+++ b/tests/unit/guid-node/workflow/expression-evaluator-test.ts
@@ -0,0 +1,114 @@
+import { evaluateExpression } from 'ember-osf-web/guid-node/workflow/-components/wizard-form/expression-evaluator';
+import { module, test } from 'qunit';
+
+module('Unit | Workflow | expression-evaluator', () => {
+ test('truthy field returns true', assert => {
+ assert.ok(evaluateExpression('name', { name: 'Alice' }));
+ });
+
+ test('missing field returns false', assert => {
+ assert.notOk(evaluateExpression('name', {}));
+ });
+
+ test('null field returns false', assert => {
+ assert.notOk(evaluateExpression('name', { name: null }));
+ });
+
+ test('empty string field returns false', assert => {
+ assert.notOk(evaluateExpression('name', { name: '' }));
+ });
+
+ test('true literal', assert => {
+ assert.ok(evaluateExpression('true', {}));
+ });
+
+ test('false literal', assert => {
+ assert.notOk(evaluateExpression('false', {}));
+ });
+
+ test('field prefixed with true is not confused', assert => {
+ assert.ok(evaluateExpression('trueness', { trueness: 'yes' }));
+ });
+
+ test('string equality', assert => {
+ assert.ok(evaluateExpression("status == 'active'", { status: 'active' }));
+ });
+
+ test('string inequality', assert => {
+ assert.ok(evaluateExpression("status != 'active'", { status: 'inactive' }));
+ });
+
+ test('OR left true', assert => {
+ assert.ok(evaluateExpression('a || b', { a: 'x', b: '' }));
+ });
+
+ test('OR right true', assert => {
+ assert.ok(evaluateExpression('a || b', { a: '', b: 'x' }));
+ });
+
+ test('OR both false', assert => {
+ assert.notOk(evaluateExpression('a || b', { a: '', b: '' }));
+ });
+
+ test('AND both true', assert => {
+ assert.ok(evaluateExpression('a && b', { a: 'x', b: 'y' }));
+ });
+
+ test('AND left false', assert => {
+ assert.notOk(evaluateExpression('a && b', { a: '', b: 'y' }));
+ });
+
+ test('NOT truthy', assert => {
+ assert.notOk(evaluateExpression('!a', { a: 'x' }));
+ });
+
+ test('NOT falsy', assert => {
+ assert.ok(evaluateExpression('!a', { a: '' }));
+ });
+
+ test('double NOT', assert => {
+ assert.ok(evaluateExpression('!!a', { a: 'x' }));
+ });
+
+ test('AND binds tighter than OR', assert => {
+ // false || (true && true) => true
+ assert.ok(evaluateExpression('a || b && c', { a: '', b: 'x', c: 'y' }));
+ });
+
+ test('NOT binds tighter than AND', assert => {
+ // (!false) && true => true
+ assert.ok(evaluateExpression('!a && b', { a: '', b: 'x' }));
+ });
+
+ test('parentheses override precedence', assert => {
+ // !(false && true) => true
+ assert.ok(evaluateExpression('!(a && b)', { a: '', b: 'x' }));
+ });
+
+ test('chained OR parses all operands', assert => {
+ assert.ok(evaluateExpression('a || b || c', { a: '', b: '', c: 'x' }));
+ });
+
+ test('chained OR all empty', assert => {
+ assert.notOk(evaluateExpression('a || b || c', { a: '', b: '', c: '' }));
+ });
+
+ test('display_template style: last || first || middle', assert => {
+ assert.ok(evaluateExpression(
+ 'last || first || middle',
+ { last: '', first: 'Taro', middle: '' },
+ ));
+ });
+
+ test('throws on empty expression', assert => {
+ assert.throws(() => evaluateExpression('', {}), /empty expression/);
+ });
+
+ test('throws on trailing garbage', assert => {
+ assert.throws(() => evaluateExpression('a b', { a: 'x' }), /unexpected/);
+ });
+
+ test('throws on unterminated string', assert => {
+ assert.throws(() => evaluateExpression("a == 'open", {}), /unterminated/);
+ });
+});
diff --git a/tests/unit/guid-node/workflow/template-evaluator-test.ts b/tests/unit/guid-node/workflow/template-evaluator-test.ts
new file mode 100644
index 000000000..a4c49ffa5
--- /dev/null
+++ b/tests/unit/guid-node/workflow/template-evaluator-test.ts
@@ -0,0 +1,258 @@
+import {
+ evaluateTemplate,
+ hasTemplateDirectives,
+} from 'ember-osf-web/guid-node/workflow/-components/wizard-form/template-evaluator';
+import { module, test } from 'qunit';
+
+module('Unit | Workflow | template-evaluator', () => {
+ // -- hasTemplateDirectives ------------------------------------------------
+
+ test('detects {{ }}', assert => {
+ assert.ok(hasTemplateDirectives('Hello {{ name }}'));
+ });
+
+ test('detects {% %}', assert => {
+ assert.ok(hasTemplateDirectives('{% if x %}yes{% endif %}'));
+ });
+
+ test('plain text has no directives', assert => {
+ assert.notOk(hasTemplateDirectives('no directives here'));
+ });
+
+ // -- variable interpolation -----------------------------------------------
+
+ test('interpolates variable', assert => {
+ assert.equal(evaluateTemplate('Hello {{ name }}!', { name: 'Alice' }), 'Hello Alice!');
+ });
+
+ test('missing variable renders empty', assert => {
+ assert.equal(evaluateTemplate('Hello {{ name }}!', {}), 'Hello !');
+ });
+
+ test('multiple variables', assert => {
+ assert.equal(
+ evaluateTemplate('{{ first }} {{ last }}', { first: 'A', last: 'B' }),
+ 'A B',
+ );
+ });
+
+ // -- if/else/endif --------------------------------------------------------
+
+ test('if truthy renders body', assert => {
+ assert.equal(
+ evaluateTemplate('{% if show %}visible{% endif %}', { show: 'yes' }),
+ 'visible',
+ );
+ });
+
+ test('if falsy skips body', assert => {
+ assert.equal(
+ evaluateTemplate('{% if show %}visible{% endif %}', { show: '' }),
+ '',
+ );
+ });
+
+ test('if/else renders else branch when falsy', assert => {
+ assert.equal(
+ evaluateTemplate('{% if show %}yes{% else %}no{% endif %}', { show: '' }),
+ 'no',
+ );
+ });
+
+ test('if/elif/else chain', assert => {
+ assert.equal(
+ evaluateTemplate(
+ '{% if a %}A{% elif b %}B{% else %}C{% endif %}',
+ { a: '', b: 'x' },
+ ),
+ 'B',
+ );
+ });
+
+ test('nested if', assert => {
+ assert.equal(
+ evaluateTemplate(
+ '{% if a %}{% if b %}AB{% endif %}{% endif %}',
+ { a: 'x', b: 'y' },
+ ),
+ 'AB',
+ );
+ });
+
+ test('nested if with outer false', assert => {
+ assert.equal(
+ evaluateTemplate(
+ '{% if a %}{% if b %}AB{% endif %}{% endif %}',
+ { a: '', b: 'y' },
+ ),
+ '',
+ );
+ });
+
+ // -- expressions in if ----------------------------------------------------
+
+ test('if with or expression', assert => {
+ assert.equal(
+ evaluateTemplate('{% if a or b %}yes{% endif %}', { a: '', b: 'x' }),
+ 'yes',
+ );
+ });
+
+ test('if with and expression', assert => {
+ assert.equal(
+ evaluateTemplate('{% if a and b %}yes{% endif %}', { a: 'x', b: '' }),
+ '',
+ );
+ });
+
+ test('if with not expression', assert => {
+ assert.equal(
+ evaluateTemplate('{% if not a %}empty{% endif %}', { a: '' }),
+ 'empty',
+ );
+ });
+
+ test('if with comparison', assert => {
+ assert.equal(
+ evaluateTemplate("{% if status == 'done' %}ok{% endif %}", { status: 'done' }),
+ 'ok',
+ );
+ });
+
+ // -- for loop -------------------------------------------------------------
+
+ test('for loop iterates', assert => {
+ assert.equal(
+ evaluateTemplate('{% for x in items %}[{{ x }}]{% endfor %}', { items: ['a', 'b'] }),
+ '[a][b]',
+ );
+ });
+
+ test('for loop with empty array', assert => {
+ assert.equal(
+ evaluateTemplate('{% for x in items %}[{{ x }}]{% endfor %}', { items: [] }),
+ '',
+ );
+ });
+
+ test('for loop with object access', assert => {
+ assert.equal(
+ evaluateTemplate(
+ '{% for p in people %}{{ p.name }} {% endfor %}',
+ { people: [{ name: 'A' }, { name: 'B' }] },
+ ),
+ 'A B ',
+ );
+ });
+
+ // -- filters --------------------------------------------------------------
+
+ test('default filter on missing value', assert => {
+ assert.equal(
+ evaluateTemplate("{{ name | default('N/A') }}", {}),
+ 'N/A',
+ );
+ });
+
+ test('default filter on present value', assert => {
+ assert.equal(
+ evaluateTemplate("{{ name | default('N/A') }}", { name: 'Alice' }),
+ 'Alice',
+ );
+ });
+
+ test('length filter', assert => {
+ assert.equal(
+ evaluateTemplate('{{ items | length }}', { items: [1, 2, 3] }),
+ '3',
+ );
+ });
+
+ // -- whitespace trimming --------------------------------------------------
+
+ test('trim before with {%-', assert => {
+ assert.equal(
+ evaluateTemplate('hello {%- if true %} world{% endif %}', {}),
+ 'hello world',
+ );
+ });
+
+ test('trim after with -%}', assert => {
+ assert.equal(
+ evaluateTemplate('{% if true -%} hello{% endif %}', {}),
+ 'hello',
+ );
+ });
+
+ // -- dot access and bracket access ----------------------------------------
+
+ test('dot access', assert => {
+ assert.equal(
+ evaluateTemplate('{{ user.name }}', { user: { name: 'Bob' } }),
+ 'Bob',
+ );
+ });
+
+ test('bracket access', assert => {
+ assert.equal(
+ evaluateTemplate("{{ data['key'] }}", { data: { key: 'val' } }),
+ 'val',
+ );
+ });
+
+ // -- display_template real-world pattern -----------------------------------
+
+ test('display_template name pattern with all fields', assert => {
+ const tpl = '{% if last or first %}{% if last %}{{ last }}, {% endif %}{{ first }} {{ middle }}{% endif %}';
+ assert.equal(
+ evaluateTemplate(tpl, { last: 'Smith', first: 'John', middle: 'A' }),
+ 'Smith, John A',
+ );
+ });
+
+ test('display_template name pattern with only first', assert => {
+ const tpl = '{% if last or first %}{% if last %}{{ last }}, {% endif %}{{ first }} {{ middle }}{% endif %}';
+ assert.equal(
+ evaluateTemplate(tpl, { last: '', first: 'Taro', middle: '' }),
+ 'Taro ',
+ );
+ });
+
+ test('display_template name pattern with all empty', assert => {
+ const tpl = '{% if last or first %}{% if last %}{{ last }}, {% endif %}{{ first }} {{ middle }}{% endif %}';
+ assert.equal(
+ evaluateTemplate(tpl, { last: '', first: '', middle: '' }),
+ '',
+ );
+ });
+
+ // -- error handling -------------------------------------------------------
+
+ test('throws on unclosed if', assert => {
+ assert.throws(
+ () => evaluateTemplate('{% if x %}hello', { x: 'y' }),
+ /unclosed/i,
+ );
+ });
+
+ test('throws on unclosed {{', assert => {
+ assert.throws(
+ () => evaluateTemplate('{{ name', {}),
+ /unclosed/i,
+ );
+ });
+
+ test('throws on unknown tag', assert => {
+ assert.throws(
+ () => evaluateTemplate('{% while true %}{% endwhile %}', {}),
+ /unknown tag/i,
+ );
+ });
+
+ test('throws on unknown filter', assert => {
+ assert.throws(
+ () => evaluateTemplate('{{ x | bogus }}', { x: 'v' }),
+ /unknown filter/i,
+ );
+ });
+});
From e15c5e16ace5d9876c32b4de9e7a8f968310ac43 Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Wed, 15 Apr 2026 09:27:12 +0900
Subject: [PATCH 08/14] Apply metadata value filter to file/project metadata
selectors
---
.../flowable-form/field/component.ts | 12 +-
.../flowable-form/field/template.hbs | 2 +
.../file-metadata-selector/component.ts | 15 +-
.../project-metadata-selector/component.ts | 12 +-
.../-components/flowable-form/utils.ts | 77 +++++++-
.../workflow/flowable-form-utils-test.ts | 179 ++++++++++++++++++
6 files changed, 290 insertions(+), 7 deletions(-)
create mode 100644 tests/unit/guid-node/workflow/flowable-form-utils-test.ts
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 3abff8a4e..24325a312 100644
--- a/app/guid-node/workflow/-components/flowable-form/field/component.ts
+++ b/app/guid-node/workflow/-components/flowable-form/field/component.ts
@@ -13,7 +13,7 @@ import { FlowableFormContext, resolveFlowableType } from '../component';
import { FieldValueWithType, WorkflowTaskField, WorkflowTaskFieldOption } from '../types';
import {
extractArrayInput, extractExportTarget, extractFileMetadata, extractFileSelector, extractFileUploader,
- extractProjectMetadata,
+ extractProjectMetadata, FilterClause,
} from '../utils';
function renderTemplateAsHtml(tmpl: string, value: Record): ReturnType {
@@ -444,6 +444,11 @@ export default class TaskFormField extends Component {
return placeholder ? placeholder.multiSelect : false;
}
+ get projectMetadataFilters(): FilterClause[] {
+ const placeholder = this.projectMetadataPlaceholder;
+ return placeholder ? placeholder.filters : [];
+ }
+
get fileMetadataPlaceholder() {
return extractFileMetadata(this.args.field);
}
@@ -461,6 +466,11 @@ export default class TaskFormField extends Component {
return placeholder ? placeholder.multiSelect : false;
}
+ get fileMetadataFilters(): FilterClause[] {
+ const placeholder = this.fileMetadataPlaceholder;
+ return placeholder ? placeholder.filters : [];
+ }
+
get isPassword(): boolean {
return this.type === 'password';
}
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 4888b68df..dc62607d5 100644
--- a/app/guid-node/workflow/-components/flowable-form/field/template.hbs
+++ b/app/guid-node/workflow/-components/flowable-form/field/template.hbs
@@ -140,6 +140,7 @@
@node={{@node}}
@schemaName={{this.projectMetadataSchemaName}}
@multiSelect={{this.projectMetadataMultiSelect}}
+ @filters={{this.projectMetadataFilters}}
@value={{this.currentValue}}
@onChange={{this.handleProjectMetadataSelection}}
@onLoadingChange={{this.handleLoadingChange}}
@@ -150,6 +151,7 @@
@node={{@node}}
@schemaName={{this.fileMetadataSchemaName}}
@multiSelect={{this.fileMetadataMultiSelect}}
+ @filters={{this.fileMetadataFilters}}
@value={{this.currentValue}}
@onChange={{this.handleFileMetadataSelection}}
@onLoadingChange={{this.handleLoadingChange}}
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 7c194b4f0..b86eb15b7 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
@@ -16,6 +16,7 @@ import pathJoin from 'ember-osf-web/utils/path-join';
import { toStringValue } from '../field/component';
import { FieldValueWithType } from '../types';
+import { FilterClause, matchesMetadataFilters } from '../utils';
const { OSF: { url: baseURL } } = config;
@@ -50,6 +51,7 @@ interface FileMetadataSelectorArgs {
node: Node;
schemaName: string;
multiSelect: boolean;
+ filters: FilterClause[];
value: FieldValueWithType | undefined;
onChange: (valueWithType: FieldValueWithType) => void;
onLoadingChange?: (isLoading: boolean) => void;
@@ -107,8 +109,7 @@ export default class FileMetadataSelector extends Component();
this.metadataNodeProject.files.forEach((entry: FileEntry) => {
- const item = entry.items.find(it => it.schema === this.schemaId);
- if (item) {
+ if (this.matchingItem(entry)) {
let path = '';
const parts = entry.path.split('/');
parts.forEach((part, i) => {
@@ -134,7 +135,7 @@ export default class FileMetadataSelector extends Component {
- const item = entry.items.find(it => it.schema === this.schemaId);
+ const item = this.matchingItem(entry);
if (item) {
const titleJa = item.data['grdm-file:title-ja'];
const titleEn = item.data['grdm-file:title-en'];
@@ -254,6 +255,14 @@ export default class FileMetadataSelector extends Component it.schema === this.schemaId);
+ if (!item) {
+ return undefined;
+ }
+ return matchesMetadataFilters(item.data, this.args.filters) ? item : undefined;
+ }
+
private notifyFileSelected(path: string): void {
const value = this.buildValueForPath(path);
this.args.onChange({
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 f9c684f83..ec3492535 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
@@ -15,6 +15,7 @@ import pathJoin from 'ember-osf-web/utils/path-join';
import { toStringValue } from '../field/component';
import { FieldValueWithType } from '../types';
+import { FilterClause, matchesMetadataFilters } from '../utils';
const { OSF: { url: baseURL } } = config;
@@ -35,6 +36,7 @@ interface ProjectMetadataSelectorArgs {
node: Node;
schemaName: string;
multiSelect: boolean;
+ filters: FilterClause[];
value: FieldValueWithType | undefined;
onChange: (valueWithType: FieldValueWithType) => void;
onLoadingChange?: (isLoading: boolean) => void;
@@ -84,8 +86,14 @@ export default class ProjectMetadataSelector extends Component matchesMetadataFilters(draft.registrationMetadata, this.args.filters),
+ );
+ const matchingRegistrations = this.registrations.filter(
+ reg => matchesMetadataFilters(reg.registeredMeta, this.args.filters),
+ );
const records = [
- ...this.draftRegistrations.map(draft => ({
+ ...matchingDrafts.map(draft => ({
guid: draft.id,
title: getMetadataDisplayTitle(draft.registrationResponses, draft.title),
dateCreated: draft.datetimeInitiated,
@@ -93,7 +101,7 @@ export default class ProjectMetadataSelector extends Component ({
+ ...matchingRegistrations.map(reg => ({
guid: reg.id,
title: getMetadataDisplayTitle(reg.registrationResponses, reg.title),
dateCreated: reg.dateCreated,
diff --git a/app/guid-node/workflow/-components/flowable-form/utils.ts b/app/guid-node/workflow/-components/flowable-form/utils.ts
index e02091a13..52d769afd 100644
--- a/app/guid-node/workflow/-components/flowable-form/utils.ts
+++ b/app/guid-node/workflow/-components/flowable-form/utils.ts
@@ -1,15 +1,65 @@
import { WorkflowTaskField } from './types';
+export interface FilterClause {
+ key: string;
+ op: '==' | '!=';
+ value: string;
+}
+
interface MetadataPlaceholder {
schemaName: string;
options: string[];
multiSelect: boolean;
+ filters: FilterClause[];
}
export type ProjectMetadataPlaceholder = MetadataPlaceholder;
export type FileMetadataPlaceholder = MetadataPlaceholder;
+const FILTER_CLAUSE_RE = /^([A-Za-z0-9:_\-.]+)\s*(==|!=)\s*"([^"]*)"$/;
+const AND_SEP = ' and ';
+const FILTER_PREFIX = 'filter=';
+
+function splitOutsideQuotes(raw: string, sep: string): string[] {
+ const out: string[] = [];
+ let buf = '';
+ let inQuotes = false;
+ let i = 0;
+ while (i < raw.length) {
+ const ch = raw[i];
+ if (ch === '"') {
+ inQuotes = !inQuotes;
+ buf += ch;
+ i += 1;
+ } else if (!inQuotes && raw.substring(i, i + sep.length) === sep) {
+ out.push(buf);
+ buf = '';
+ i += sep.length;
+ } else {
+ buf += ch;
+ i += 1;
+ }
+ }
+ if (inQuotes) {
+ throw new Error(`Unterminated quoted string: ${raw}`);
+ }
+ out.push(buf);
+ return out;
+}
+
+export function parseFilterExpression(raw: string): FilterClause[] {
+ const clauses = splitOutsideQuotes(raw, AND_SEP);
+ return clauses.map(clause => {
+ const trimmed = clause.trim();
+ const m = trimmed.match(FILTER_CLAUSE_RE);
+ if (!m) {
+ throw new Error(`Invalid filter clause: ${trimmed}`);
+ }
+ return { key: m[1], op: m[2] as '==' | '!=', value: m[3] };
+ });
+}
+
function extractMetadataPlaceholder(field: WorkflowTaskField, token: '_PROJECT_METADATA' | '_FILE_METADATA'):
MetadataPlaceholder | null {
if (field.type !== 'multi-line-text') {
@@ -25,16 +75,26 @@ MetadataPlaceholder | null {
return null;
}
const raw = match[1];
- const segments = raw.split(',').map(part => part.trim()).filter(part => part.length > 0);
+ const segments = splitOutsideQuotes(raw, ',').map(part => part.trim()).filter(part => part.length > 0);
if (segments.length === 0) {
return null;
}
const [schemaName, ...options] = segments;
+
+ const filterOptions = options.filter(o => o.startsWith(FILTER_PREFIX));
+ if (filterOptions.length > 1) {
+ throw new Error(`${token}: duplicate 'filter=' option`);
+ }
+ const filters: FilterClause[] = filterOptions.length === 1
+ ? parseFilterExpression(filterOptions[0].substring(FILTER_PREFIX.length))
+ : [];
+
const normalizedOptions = options.map(option => option.toUpperCase());
return {
schemaName,
options,
multiSelect: normalizedOptions.includes('MULTISELECT'),
+ filters,
};
}
@@ -111,3 +171,18 @@ export function extractArrayInput(field: WorkflowTaskField): ArrayInputPlacehold
const fields: WorkflowTaskField[] = JSON.parse(match[1]);
return { fields };
}
+
+export function matchesMetadataFilters(
+ data: { [key: string]: { value?: unknown } | undefined } | null | undefined,
+ filters: FilterClause[],
+): boolean {
+ if (filters.length === 0) {
+ return true;
+ }
+ const source = data || {};
+ return filters.every(f => {
+ const entry = source[f.key];
+ const value = entry ? entry.value : undefined;
+ return f.op === '==' ? value === f.value : value !== f.value;
+ });
+}
diff --git a/tests/unit/guid-node/workflow/flowable-form-utils-test.ts b/tests/unit/guid-node/workflow/flowable-form-utils-test.ts
new file mode 100644
index 000000000..4d3c1c8d8
--- /dev/null
+++ b/tests/unit/guid-node/workflow/flowable-form-utils-test.ts
@@ -0,0 +1,179 @@
+import { WorkflowTaskField } from 'ember-osf-web/guid-node/workflow/-components/flowable-form/types';
+import {
+ extractFileMetadata,
+ extractProjectMetadata,
+ matchesMetadataFilters,
+ parseFilterExpression,
+} from 'ember-osf-web/guid-node/workflow/-components/flowable-form/utils';
+import { module, test } from 'qunit';
+
+function buildField(placeholder: string): WorkflowTaskField {
+ return {
+ id: 'f1',
+ name: 'f1',
+ type: 'multi-line-text',
+ placeholder,
+ } as WorkflowTaskField;
+}
+
+module('Unit | Workflow | flowable-form-utils', () => {
+ module('extractFileMetadata', () => {
+ test('no options → empty filters, multiSelect false', assert => {
+ const meta = extractFileMetadata(buildField('_FILE_METADATA(my-schema)'));
+ assert.ok(meta);
+ assert.strictEqual(meta!.schemaName, 'my-schema');
+ assert.notOk(meta!.multiSelect);
+ assert.deepEqual(meta!.filters, []);
+ });
+
+ test('MULTISELECT only', assert => {
+ const meta = extractFileMetadata(buildField('_FILE_METADATA(my-schema, MULTISELECT)'));
+ assert.ok(meta!.multiSelect);
+ assert.deepEqual(meta!.filters, []);
+ });
+
+ test('single == clause', assert => {
+ const meta = extractFileMetadata(buildField(
+ '_FILE_METADATA(my-schema, filter=grdm-file:file-type=="dataset")',
+ ));
+ assert.deepEqual(meta!.filters, [
+ { key: 'grdm-file:file-type', op: '==', value: 'dataset' },
+ ]);
+ });
+
+ test('single != clause', assert => {
+ const meta = extractFileMetadata(buildField(
+ '_FILE_METADATA(my-schema, filter=grdm-file:file-type!="manuscript")',
+ ));
+ assert.deepEqual(meta!.filters, [
+ { key: 'grdm-file:file-type', op: '!=', value: 'manuscript' },
+ ]);
+ });
+
+ test('multiple clauses with and, mixed operators', assert => {
+ const meta = extractFileMetadata(buildField(
+ '_FILE_METADATA(my-schema, filter=a=="x" and b!="y")',
+ ));
+ assert.deepEqual(meta!.filters, [
+ { key: 'a', op: '==', value: 'x' },
+ { key: 'b', op: '!=', value: 'y' },
+ ]);
+ });
+
+ test('MULTISELECT and filter together', assert => {
+ const meta = extractFileMetadata(buildField(
+ '_FILE_METADATA(my-schema, MULTISELECT, filter=k=="v")',
+ ));
+ assert.ok(meta!.multiSelect);
+ assert.deepEqual(meta!.filters, [{ key: 'k', op: '==', value: 'v' }]);
+ });
+
+ test('quoted value containing comma is preserved', assert => {
+ const meta = extractFileMetadata(buildField(
+ '_FILE_METADATA(my-schema, filter=k=="a,b")',
+ ));
+ assert.deepEqual(meta!.filters, [{ key: 'k', op: '==', value: 'a,b' }]);
+ });
+
+ test('duplicate filter= throws', assert => {
+ assert.throws(
+ () => extractFileMetadata(buildField(
+ '_FILE_METADATA(my-schema, filter=a=="x", filter=b=="y")',
+ )),
+ /duplicate 'filter='/,
+ );
+ });
+
+ test('unquoted value throws', assert => {
+ assert.throws(
+ () => extractFileMetadata(buildField('_FILE_METADATA(my-schema, filter=k==dataset)')),
+ /Invalid filter clause/,
+ );
+ });
+
+ test('single = throws', assert => {
+ assert.throws(
+ () => extractFileMetadata(buildField('_FILE_METADATA(my-schema, filter=k="v")')),
+ /Invalid filter clause/,
+ );
+ });
+
+ test('unknown operator throws', assert => {
+ assert.throws(
+ () => extractFileMetadata(buildField('_FILE_METADATA(my-schema, filter=k<>"v")')),
+ /Invalid filter clause/,
+ );
+ });
+
+ test('or connector throws', assert => {
+ assert.throws(
+ () => extractFileMetadata(buildField('_FILE_METADATA(my-schema, filter=a=="x" or b=="y")')),
+ /Invalid filter clause/,
+ );
+ });
+ });
+
+ module('extractProjectMetadata', () => {
+ test('parses filter the same way', assert => {
+ const meta = extractProjectMetadata(buildField(
+ '_PROJECT_METADATA(my-schema, MULTISELECT, filter=status=="ready")',
+ ));
+ assert.strictEqual(meta!.schemaName, 'my-schema');
+ assert.ok(meta!.multiSelect);
+ assert.deepEqual(meta!.filters, [{ key: 'status', op: '==', value: 'ready' }]);
+ });
+ });
+
+ module('parseFilterExpression', () => {
+ test('splits on " and " outside quotes', assert => {
+ assert.deepEqual(
+ parseFilterExpression('k=="a and b" and x!="y"'),
+ [
+ { key: 'k', op: '==', value: 'a and b' },
+ { key: 'x', op: '!=', value: 'y' },
+ ],
+ );
+ });
+
+ test('empty clause throws', assert => {
+ assert.throws(() => parseFilterExpression(''), /Invalid filter clause/);
+ });
+ });
+
+ module('matchesMetadataFilters', () => {
+ const dataDataset = { 'grdm-file:file-type': { value: 'dataset' } };
+ const dataManuscript = { 'grdm-file:file-type': { value: 'manuscript' } };
+ const dataEmpty = {};
+
+ test('no filters → always true', assert => {
+ assert.ok(matchesMetadataFilters(dataEmpty, []));
+ });
+
+ test('== matches on explicit value', assert => {
+ const filters = [{ key: 'grdm-file:file-type', op: '==' as const, value: 'dataset' }];
+ assert.ok(matchesMetadataFilters(dataDataset, filters));
+ assert.notOk(matchesMetadataFilters(dataManuscript, filters));
+ });
+
+ test('== does not match when unset', assert => {
+ const filters = [{ key: 'grdm-file:file-type', op: '==' as const, value: 'dataset' }];
+ assert.notOk(matchesMetadataFilters(dataEmpty, filters));
+ });
+
+ test('!= matches when unset (covers default)', assert => {
+ const filters = [{ key: 'grdm-file:file-type', op: '!=' as const, value: 'manuscript' }];
+ assert.ok(matchesMetadataFilters(dataEmpty, filters));
+ assert.ok(matchesMetadataFilters(dataDataset, filters));
+ assert.notOk(matchesMetadataFilters(dataManuscript, filters));
+ });
+
+ test('multiple filters are AND-joined', assert => {
+ const filters = [
+ { key: 'a', op: '==' as const, value: 'x' },
+ { key: 'b', op: '==' as const, value: 'y' },
+ ];
+ assert.ok(matchesMetadataFilters({ a: { value: 'x' }, b: { value: 'y' } }, filters));
+ assert.notOk(matchesMetadataFilters({ a: { value: 'x' }, b: { value: 'z' } }, filters));
+ });
+ });
+});
From 201936b164af5442686ebcf72baa0dcdd1a4eeb0 Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Fri, 17 Apr 2026 11:29:34 +0900
Subject: [PATCH 09/14] Fix flowable form: delayed ArrayInput init and dropdown
empty-value validation
---
.../flowable-form/array-input/component.ts | 16 ++++++++--------
.../flowable-form/array-input/template.hbs | 2 +-
.../-components/flowable-form/field/component.ts | 3 ++-
3 files changed, 11 insertions(+), 10 deletions(-)
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
index 1c261c4cc..7d59c6c9d 100644
--- a/app/guid-node/workflow/-components/flowable-form/array-input/component.ts
+++ b/app/guid-node/workflow/-components/flowable-form/array-input/component.ts
@@ -48,16 +48,16 @@ export default class ArrayInput extends Component {
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 },
- }));
+ if (!existing || !Array.isArray(existing.value)) {
+ return;
}
+ this.isInitialized = true;
+ const items = existing.value as Array>;
+ this.rows = items.map(item => ({
+ key: this.allocateKey(),
+ values: { ...item },
+ }));
}
@action
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
index a3f00a10b..d54fefab5 100644
--- a/app/guid-node/workflow/-components/flowable-form/array-input/template.hbs
+++ b/app/guid-node/workflow/-components/flowable-form/array-input/template.hbs
@@ -1,4 +1,4 @@
-
+
{{#each this.rowForms as |entry|}}
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 24325a312..a3ef2616d 100644
--- a/app/guid-node/workflow/-components/flowable-form/field/component.ts
+++ b/app/guid-node/workflow/-components/flowable-form/field/component.ts
@@ -41,7 +41,8 @@ function isValidFieldValue(field: WorkflowTaskField, value: unknown): boolean {
const type = field.type.toLowerCase();
if (['dropdown', 'select', 'radio-buttons', 'radio'].includes(type)) {
const options = field.options || [];
- const validValues = options.map(getOptionValue).filter(v => v !== undefined && v !== null && v !== '');
+ const selectable = field.hasEmptyValue ? options.slice(1) : options;
+ const validValues = selectable.map(getOptionValue).filter(v => v !== undefined && v !== null && v !== '');
if (validValues.length > 0 && !validValues.includes(String(value))) {
return false;
}
From 27744ec9609c42d043e4833db59eafbc58548d56 Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Sun, 19 Apr 2026 16:48:36 +0900
Subject: [PATCH 10/14] Restore locale-aware combined label for single-select
pulldown options
---
.../rdm/single-select-pulldown-input/component.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/editable/rdm/single-select-pulldown-input/component.ts b/lib/osf-components/addon/components/registries/schema-block-renderer/editable/rdm/single-select-pulldown-input/component.ts
index 881c84632..bba51d98d 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/editable/rdm/single-select-pulldown-input/component.ts
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/editable/rdm/single-select-pulldown-input/component.ts
@@ -54,8 +54,7 @@ export default class SingleSelectPulldownInput extends Component {
@action
onChange(option: string) {
- const code = (option || '').trim();
- const item = this.optionBlocks.find(b => code === b.displayText);
+ const item = this.optionBlocks.find(b => this.getLocalizedItemText(b) === option);
const result = item ? item.displayText : option;
this.changeset.set(this.valuePath, result);
this.onMetadataInput();
@@ -73,11 +72,14 @@ export default class SingleSelectPulldownInput extends Component {
}
getLocalizedItemText(item: SchemaBlock) {
- const text = item.helpText || item.displayText;
- if (text === undefined) {
- return item.displayText;
+ if (item.displayText && item.displayText.includes('|')) {
+ return this.getLocalizedText(item.displayText);
}
- return `${item.displayText}`;
+ if (!item.helpText) {
+ return `${item.displayText}`;
+ }
+ const label = this.getLocalizedText(item.helpText);
+ return `${label} | ${item.displayText}`;
}
getLocalizedText(text: string) {
From 7b284fd816daf40904275fceff0fc51734814e8d Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Mon, 20 Apr 2026 06:56:53 +0900
Subject: [PATCH 11/14] Log workflow draft storage errors
---
.../-components/wizard-form/draft-utils.ts | 30 ++++++++++++++-----
1 file changed, 22 insertions(+), 8 deletions(-)
diff --git a/app/guid-node/workflow/-components/wizard-form/draft-utils.ts b/app/guid-node/workflow/-components/wizard-form/draft-utils.ts
index ccbb42710..8a756ec5e 100644
--- a/app/guid-node/workflow/-components/wizard-form/draft-utils.ts
+++ b/app/guid-node/workflow/-components/wizard-form/draft-utils.ts
@@ -1,3 +1,5 @@
+import captureException from 'ember-osf-web/utils/capture-exception';
+
const PREFIX = 'rdm-wizard:';
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -31,8 +33,10 @@ export function saveDraft(
savedAt: Date.now(),
};
localStorage.setItem(storageKey(taskId), JSON.stringify(draft));
- } catch {
- // localStorage unavailable or full — silently skip
+ } catch (error) {
+ captureException(error as Error, {
+ errorMessage: `Failed to save workflow draft for task ${taskId}`,
+ });
}
}
@@ -52,7 +56,10 @@ export function loadDraft(taskId: string, formKey: string): WizardDraft | null {
return null;
}
return draft;
- } catch {
+ } catch (error) {
+ captureException(error as Error, {
+ errorMessage: `Failed to load workflow draft for task ${taskId}`,
+ });
return null;
}
}
@@ -60,8 +67,10 @@ export function loadDraft(taskId: string, formKey: string): WizardDraft | null {
export function clearDraft(taskId: string): void {
try {
localStorage.removeItem(storageKey(taskId));
- } catch {
- // silently skip
+ } catch (error) {
+ captureException(error as Error, {
+ errorMessage: `Failed to clear workflow draft for task ${taskId}`,
+ });
}
}
@@ -81,11 +90,16 @@ export function collectExpiredDrafts(): void {
localStorage.removeItem(key);
}
}
- } catch {
+ } catch (error) {
+ captureException(error as Error, {
+ errorMessage: `Failed to inspect workflow draft ${key}`,
+ });
localStorage.removeItem(key!);
}
}
- } catch {
- // silently skip
+ } catch (error) {
+ captureException(error as Error, {
+ errorMessage: 'Failed to collect expired workflow drafts',
+ });
}
}
From 4f82e28200491998c9eb1d6c91a73e5d7ece09a3 Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Tue, 21 Apr 2026 11:56:22 +0900
Subject: [PATCH 12/14] Preserve active workflow tab across reloads via hash
---
app/guid-node/workflow/controller.ts | 43 ++++++++++++++++++++++------
1 file changed, 35 insertions(+), 8 deletions(-)
diff --git a/app/guid-node/workflow/controller.ts b/app/guid-node/workflow/controller.ts
index befb38474..dd3cead73 100644
--- a/app/guid-node/workflow/controller.ts
+++ b/app/guid-node/workflow/controller.ts
@@ -285,6 +285,7 @@ export default class GuidNodeWorkflowController extends Controller {
cancelled: this.intl.t('workflow.console.status.cancelled') as string,
};
+ const tabFromHash = this.extractTabFromHash(hash);
const hasStartHash = this.updateSelectionFromHash(hash);
const taskToOpen = this.extractTaskFromHash(hash);
this.refreshRuns();
@@ -295,7 +296,7 @@ export default class GuidNodeWorkflowController extends Controller {
if (task) {
this.openTask(task);
}
- } else if (!hasStartHash) {
+ } else if (!tabFromHash && !hasStartHash) {
const hasAssignedTasks = this.tasksWithActions.some(task => task.canComplete);
if (hasAssignedTasks) {
this.activeTab = 'tasks';
@@ -309,6 +310,10 @@ export default class GuidNodeWorkflowController extends Controller {
return false;
}
const params = new URLSearchParams(hash.replace(/^#/, ''));
+ const tabValue = params.get('tab');
+ if (tabValue === 'start' || tabValue === 'runs' || tabValue === 'tasks') {
+ this.activeTab = tabValue;
+ }
const startValue = params.get('start');
if (!startValue) {
return false;
@@ -353,6 +358,19 @@ export default class GuidNodeWorkflowController extends Controller {
return null;
}
+ extractTabFromHash(hash?: string): 'start' | 'runs' | 'tasks' | null {
+ const candidate = hash !== undefined ? hash : window.location.hash;
+ if (!candidate) {
+ return null;
+ }
+ const params = new URLSearchParams(candidate.replace(/^#/, ''));
+ const value = params.get('tab');
+ if (value === 'start' || value === 'runs' || value === 'tasks') {
+ return value;
+ }
+ return null;
+ }
+
formatDate(value?: string | null): string {
if (!value) {
return '';
@@ -373,12 +391,8 @@ export default class GuidNodeWorkflowController extends Controller {
this.submitSuccess = null;
this.startFormVariables = [];
this.prefilledStartFormVariables = [];
- if (value) {
- window.location.hash = `start=${encodeURIComponent(value)}`;
- } else {
- const { pathname, search } = window.location;
- window.history.replaceState(null, document.title, `${pathname}${search}`);
- }
+ const { pathname, search } = window.location;
+ window.history.replaceState(null, document.title, `${pathname}${search}#${this.hashForTab('start', value)}`);
}
runStatusLabel(run: WorkflowRunSummary & { statusRaw?: unknown }): string {
@@ -398,11 +412,17 @@ export default class GuidNodeWorkflowController extends Controller {
}
@action
- setActiveTab(tab: 'start' | 'runs' | 'tasks'): void {
+ setActiveTab(tab: 'start' | 'runs' | 'tasks', event?: Event): void {
+ if (event) {
+ event.preventDefault();
+ }
if (this.activeTab === tab) {
return;
}
this.activeTab = tab;
+ const { pathname, search } = window.location;
+ const hash = this.hashForTab(tab, this.selectedTemplateId);
+ window.history.replaceState(null, document.title, `${pathname}${search}#${hash}`);
if (tab === 'runs' && !this.runsLoaded) {
this.refreshRuns();
}
@@ -806,6 +826,13 @@ export default class GuidNodeWorkflowController extends Controller {
}
return assignee;
}
+
+ private hashForTab(tab: 'start' | 'runs' | 'tasks', selectedTemplateId: string): string {
+ if (tab === 'start' && selectedTemplateId) {
+ return `tab=start&start=${encodeURIComponent(selectedTemplateId)}`;
+ }
+ return `tab=${tab}`;
+ }
}
declare module '@ember/controller' {
From 0ff4878e4fd495ef9942079b8b70c26a765d6ec0 Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Fri, 24 Apr 2026 15:16:04 +0900
Subject: [PATCH 13/14] Allow HTML in tag/item info popovers and switch info
mark to Font Awesome
---
app/packages/registration-schema/ui-group.ts | 16 +++++++++++-----
.../label/label-content/component.ts | 6 +++---
.../label/label-content/styles.scss | 2 +-
.../label/label-content/template.hbs | 2 +-
.../registries/ui-visual-items/component.ts | 1 -
.../registries/ui-visual-items/styles.scss | 3 +--
.../registries/ui-visual-items/template.hbs | 2 +-
7 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/app/packages/registration-schema/ui-group.ts b/app/packages/registration-schema/ui-group.ts
index 3fb2b61e0..bc656d014 100644
--- a/app/packages/registration-schema/ui-group.ts
+++ b/app/packages/registration-schema/ui-group.ts
@@ -1,3 +1,5 @@
+import { htmlSafe } from '@ember/template';
+
import { SchemaBlock, SchemaBlockGroup } from 'ember-osf-web/packages/registration-schema';
// TODO: condition evaluation is not implemented on the Ember side.
@@ -17,7 +19,7 @@ function resolveUI(ui: SchemaBlock['ui']): Record
| undefined {
export interface ResolvedTag {
id: string;
localizedText: string;
- info?: string;
+ info?: ReturnType;
}
export interface UiGroupDef {
@@ -33,7 +35,11 @@ export interface UiGroupDef {
export interface VisualItem {
schemaBlockGroup?: SchemaBlockGroup;
- uiGroup?: UiGroupDef & { localizedTitle?: string; localizedInfo?: string; resolvedTags?: ResolvedTag[] };
+ uiGroup?: UiGroupDef & {
+ localizedTitle?: string;
+ localizedInfo?: ReturnType;
+ resolvedTags?: ResolvedTag[];
+ };
children?: VisualItem[];
responseKeys?: string[];
}
@@ -62,7 +68,7 @@ export function resolveTags(
return {
id: tag.id,
localizedText: localizeText(tag.id),
- info: tag.info ? localizeText(tag.info) : undefined,
+ info: tag.info ? htmlSafe(localizeText(tag.info)) : undefined,
};
}
const def = tagDefs[tag];
@@ -72,7 +78,7 @@ export function resolveTags(
return {
id: tag,
localizedText: localizeText(tag),
- info: def.info ? localizeText(def.info) : undefined,
+ info: def.info ? htmlSafe(localizeText(def.info)) : undefined,
};
});
}
@@ -111,7 +117,7 @@ export function buildVisualItems(
uiGroup: {
...def,
localizedTitle: def.title ? localizeText(def.title) : undefined,
- localizedInfo: def.info ? localizeText(def.info) : undefined,
+ localizedInfo: def.info ? htmlSafe(localizeText(def.info)) : undefined,
resolvedTags: resolveTags(def.tags, tagDefs, localizeText),
},
children: [],
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts
index b9b9dc1f8..37076e1bb 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/component.ts
@@ -2,6 +2,7 @@ import { tagName } from '@ember-decorators/component';
import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { inject as service } from '@ember/service';
+import { htmlSafe } from '@ember/template';
import Intl from 'ember-intl/services/intl';
import { layout } from 'ember-osf-web/decorators/component';
@@ -26,7 +27,6 @@ export default class LabelContent extends Component {
// Private property
shouldShowExample = false;
circleMarker = '\u25CB';
- infoMark = '\u24D8';
@computed('inputBlockUI')
get displayTextOverride(): string | undefined {
@@ -39,9 +39,9 @@ export default class LabelContent extends Component {
}
@computed('inputBlockUI')
- get itemInfo(): string | undefined {
+ get itemInfo(): ReturnType | undefined {
const info = this.inputBlockUI && this.inputBlockUI.item && this.inputBlockUI.item.info;
- return info ? this.getLocalizedText(info) : undefined;
+ return info ? htmlSafe(this.getLocalizedText(info)) : undefined;
}
@computed('inputBlockUI', 'tagDefs')
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/styles.scss b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/styles.scss
index 56d00da99..60d021b2e 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/styles.scss
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/styles.scss
@@ -50,7 +50,7 @@
.InfoMark {
cursor: pointer;
margin-left: 6px;
- color: #5bc0de;
+ color: #3779b3;
}
.TagBadge {
diff --git a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs
index 5490b81cd..159f6fd6a 100644
--- a/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs
+++ b/lib/osf-components/addon/components/registries/schema-block-renderer/label/label-content/template.hbs
@@ -20,7 +20,7 @@
{{~/each~}}
{{~#if this.itemInfo~}}
- {{this.infoMark}}
+
{{this.itemInfo}}
diff --git a/lib/osf-components/addon/components/registries/ui-visual-items/component.ts b/lib/osf-components/addon/components/registries/ui-visual-items/component.ts
index a0af2b784..784c7802f 100644
--- a/lib/osf-components/addon/components/registries/ui-visual-items/component.ts
+++ b/lib/osf-components/addon/components/registries/ui-visual-items/component.ts
@@ -17,7 +17,6 @@ export default class UiVisualItems extends Component {
changeset?: any;
isTopLevel: boolean = false;
circleMarker = '\u25CB';
- infoMark = '\u24D8';
@action
preventLabelFocus(event: Event) {
diff --git a/lib/osf-components/addon/components/registries/ui-visual-items/styles.scss b/lib/osf-components/addon/components/registries/ui-visual-items/styles.scss
index dcbde1665..a076c033c 100644
--- a/lib/osf-components/addon/components/registries/ui-visual-items/styles.scss
+++ b/lib/osf-components/addon/components/registries/ui-visual-items/styles.scss
@@ -8,7 +8,6 @@
.UiGroupHeading {
font-weight: bold;
- font-size: 1.1em;
margin: 0 0 5px;
+ :global(label),
@@ -20,7 +19,7 @@
.InfoMark {
cursor: pointer;
margin-left: 6px;
- color: #5bc0de;
+ color: #3779b3;
}
.TagBadge {
diff --git a/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs b/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs
index 3a8472b65..c382b4423 100644
--- a/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs
+++ b/lib/osf-components/addon/components/registries/ui-visual-items/template.hbs
@@ -16,7 +16,7 @@
{{/each}}
{{#if item.uiGroup.localizedInfo}}
- {{this.infoMark}}
+
{{item.uiGroup.localizedInfo}}
From 84f72eac2a72b00f345fba5bf9b087dff684880b Mon Sep 17 00:00:00 2001
From: yacchin1205 <968739+yacchin1205@users.noreply.github.com>
Date: Mon, 27 Apr 2026 14:19:48 +0900
Subject: [PATCH 14/14] Refine workflow task console with assignee user link,
adaptive project column, and completion column
---
.../-components/task-dialog/component.ts | 34 ++++++------
.../-components/task-dialog/template.hbs | 10 +++-
app/guid-node/workflow/controller.ts | 52 ++++++++----------
app/guid-node/workflow/route.ts | 5 ++
app/guid-node/workflow/template.hbs | 54 ++++++++++++-------
app/guid-node/workflow/types.ts | 7 +++
app/guid-node/workflow/utils.ts | 41 +++++++++++++-
translations/en-us.yml | 1 +
translations/ja.yml | 1 +
9 files changed, 137 insertions(+), 68 deletions(-)
diff --git a/app/guid-node/workflow/-components/task-dialog/component.ts b/app/guid-node/workflow/-components/task-dialog/component.ts
index aeaf59a37..a29f0c5e1 100644
--- a/app/guid-node/workflow/-components/task-dialog/component.ts
+++ b/app/guid-node/workflow/-components/task-dialog/component.ts
@@ -2,6 +2,7 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
+import config from 'ember-get-config';
import Intl from 'ember-intl/services/intl';
import Node from 'ember-osf-web/models/node';
@@ -11,6 +12,7 @@ import {
WorkflowTaskDetail,
WorkflowVariable,
} from '../../types';
+import { workflowAssigneeDisplay } from '../../utils';
import { isFinalStep } from '../progress-sidebar/utils';
import { extractWizardConfig, WizardNavigation } from '../wizard-form/types';
@@ -45,25 +47,21 @@ export default class WorkflowTaskDialog extends Component{{t 'workflow.console.tasks.columns.task'}}
- {{or this.taskTitle (t 'workflow.console.tasks.dialog.untitled')}}
- {{t 'workflow.console.tasks.columns.assignee'}}
- - {{this.assigneeLabel}}
+ -
+ {{#if this.assigneeUrl}}
+
+ {{this.assigneeLabel}}
+
+ {{else}}
+ {{this.assigneeLabel}}
+ {{/if}}
+
- {{t 'workflow.console.tasks.dialog.startedAt'}}
- {{or @task.created (t 'workflow.console.tasks.dialog.notAvailable')}}
- {{t 'workflow.console.tasks.dialog.dueAt'}}
diff --git a/app/guid-node/workflow/controller.ts b/app/guid-node/workflow/controller.ts
index dd3cead73..a947003ce 100644
--- a/app/guid-node/workflow/controller.ts
+++ b/app/guid-node/workflow/controller.ts
@@ -21,6 +21,7 @@ import {
WorkflowTemplate,
WorkflowVariable,
} from './types';
+import { workflowAssigneeDisplay } from './utils';
export {
WorkflowTemplate,
@@ -119,6 +120,7 @@ export default class GuidNodeWorkflowController extends Controller {
@tracked templates: WorkflowTemplate[] = [];
@tracked pendingTemplates: PendingTemplate[] = [];
+ @tracked providesTemplates = false;
@tracked templatesError: string | null = null;
@tracked isRefreshing = false;
@@ -185,22 +187,31 @@ export default class GuidNodeWorkflowController extends Controller {
get tasksWithActions(): Array<
WorkflowTaskSummary & {
- canComplete: boolean; assigneeDisplay: string; projectUrl: string; isCurrentProject: boolean;
+ canComplete: boolean;
+ assigneeDisplay: string;
+ assigneeUrl: string | null;
+ projectUrl: string;
+ isCurrentProject: boolean;
}
> {
if (!this.node) {
return [];
}
const currentNodeId = this.node.id;
- return this.tasks.map(task => ({
- ...task,
- created: this.formatDate(task.created),
- due: this.formatDate(task.due),
- canComplete: task.can_complete !== false,
- assigneeDisplay: this.assigneeLabel(task.assignee),
- projectUrl: pathJoin(config.OSF.url, task.node_id),
- isCurrentProject: task.node_id === currentNodeId,
- }));
+ return this.tasks.map(task => {
+ const assignee = workflowAssigneeDisplay(this.intl, task.assignee, task.assignee_user, config.OSF.url);
+ return {
+ ...task,
+ created: this.formatDate(task.created),
+ due: this.formatDate(task.due),
+ completed: this.formatDate(task.completed),
+ canComplete: task.can_complete !== false,
+ assigneeDisplay: assignee.label,
+ assigneeUrl: assignee.url,
+ projectUrl: pathJoin(config.OSF.url, task.node_id),
+ isCurrentProject: task.node_id === currentNodeId,
+ };
+ });
}
get assignedTaskCount(): number {
@@ -275,6 +286,7 @@ export default class GuidNodeWorkflowController extends Controller {
this.apiBaseUrl = ensureTrailingSlash(model.apiBaseUrl);
this.templates = model.templates;
this.pendingTemplates = model.pendingTemplates;
+ this.providesTemplates = model.providesTemplates;
this.templatesError = model.templatesError || null;
this.runStatusLabels = {
@@ -807,26 +819,6 @@ export default class GuidNodeWorkflowController extends Controller {
throw new Error('Job timed out');
}
- private assigneeLabel(assignee?: string): string {
- if (!assignee) {
- return this.intl.t('workflow.console.tasks.dialog.unassigned') as string;
- }
- const lower = assignee.toLowerCase();
- if (lower === 'executor') {
- return this.intl.t('workflow.console.tasks.assignee.executor') as string;
- }
- if (lower === 'creator') {
- return this.intl.t('workflow.console.tasks.assignee.creator') as string;
- }
- if (lower === 'manager') {
- return this.intl.t('workflow.console.tasks.assignee.manager') as string;
- }
- if (lower === 'contributor') {
- return this.intl.t('workflow.console.tasks.assignee.contributor') as string;
- }
- return assignee;
- }
-
private hashForTab(tab: 'start' | 'runs' | 'tasks', selectedTemplateId: string): string {
if (tab === 'start' && selectedTemplateId) {
return `tab=start&start=${encodeURIComponent(selectedTemplateId)}`;
diff --git a/app/guid-node/workflow/route.ts b/app/guid-node/workflow/route.ts
index 71b8a4069..f9337a926 100644
--- a/app/guid-node/workflow/route.ts
+++ b/app/guid-node/workflow/route.ts
@@ -49,6 +49,7 @@ interface RouteModel {
node: Node;
templates: WorkflowTemplate[];
pendingTemplates: PendingTemplate[];
+ providesTemplates: boolean;
apiBaseUrl: string;
templatesError?: string | null;
}
@@ -90,6 +91,7 @@ export default class GuidNodeWorkflowRoute extends Route {
let templates: WorkflowTemplate[] = [];
let pendingTemplates: PendingTemplate[] = [];
+ let providesTemplates = false;
let templatesError: string | null = null;
try {
@@ -105,6 +107,7 @@ export default class GuidNodeWorkflowRoute extends Route {
]);
templates = normalizeTemplates(activationsResponse.data);
pendingTemplates = extractPendingTemplates(templatesResponse.data);
+ providesTemplates = templatesResponse.data.some(t => t.is_local);
} catch (error) {
templatesError = extractErrorMessage(error);
}
@@ -113,6 +116,7 @@ export default class GuidNodeWorkflowRoute extends Route {
node,
templates,
pendingTemplates,
+ providesTemplates,
apiBaseUrl,
templatesError,
};
@@ -126,6 +130,7 @@ export default class GuidNodeWorkflowRoute extends Route {
node: model.node,
templates: model.templates,
pendingTemplates: model.pendingTemplates,
+ providesTemplates: model.providesTemplates,
apiBaseUrl: model.apiBaseUrl,
templatesError: model.templatesError,
},
diff --git a/app/guid-node/workflow/template.hbs b/app/guid-node/workflow/template.hbs
index 55002e30b..5fac9eb2c 100644
--- a/app/guid-node/workflow/template.hbs
+++ b/app/guid-node/workflow/template.hbs
@@ -250,7 +250,9 @@
| {{t 'workflow.console.runs.columns.processId'}} |
- {{t 'workflow.console.runs.columns.project'}} |
+ {{#if this.providesTemplates}}
+ {{t 'workflow.console.runs.columns.project'}} |
+ {{/if}}
{{t 'workflow.console.runs.columns.status'}} |
{{t 'workflow.console.runs.columns.started'}} |
{{t 'workflow.console.runs.columns.completed'}} |
@@ -263,15 +265,17 @@
{{run.engine_process_id}}
|
-
- {{#if run.isCurrentProject}}
- {{t 'workflow.console.runs.columns.thisProject'}}
- {{else}}
-
- {{run.node_title}}
-
- {{/if}}
- |
+ {{#if this.providesTemplates}}
+
+ {{#if run.isCurrentProject}}
+ {{t 'workflow.console.runs.columns.thisProject'}}
+ {{else}}
+
+ {{run.node_title}}
+
+ {{/if}}
+ |
+ {{/if}}
{{or run.statusRaw run.status}}
@@ -391,10 +395,13 @@
| {{t 'workflow.console.tasks.columns.task'}} |
- {{t 'workflow.console.tasks.columns.project'}} |
+ {{#if this.providesTemplates}}
+ {{t 'workflow.console.tasks.columns.project'}} |
+ {{/if}}
{{t 'workflow.console.tasks.columns.assignee'}} |
{{t 'workflow.console.tasks.columns.created'}} |
{{t 'workflow.console.tasks.columns.due'}} |
+ {{t 'workflow.console.tasks.columns.completed'}} |
{{t 'workflow.console.tasks.columns.status'}} |
{{t 'workflow.console.tasks.columns.actions'}} |
@@ -406,18 +413,29 @@
{{task.name}}
{{task.business_key}}
+ {{#if this.providesTemplates}}
+
+ {{#if task.isCurrentProject}}
+ {{t 'workflow.console.tasks.columns.thisProject'}}
+ {{else}}
+
+ {{task.node_title}}
+
+ {{/if}}
+ |
+ {{/if}}
- {{#if task.isCurrentProject}}
- {{t 'workflow.console.tasks.columns.thisProject'}}
- {{else}}
-
- {{task.node_title}}
+ {{#if task.assigneeUrl}}
+
+ {{task.assigneeDisplay}}
+ {{else}}
+ {{task.assigneeDisplay}}
{{/if}}
|
- {{task.assigneeDisplay}} |
{{task.created}} |
- {{task.due}} |
+ {{or task.due '-'}} |
+ {{or task.completed '-'}} |
{{#if (eq task.task_status 'completed')}}
{{t 'workflow.console.tasks.statuses.completed'}}
diff --git a/app/guid-node/workflow/types.ts b/app/guid-node/workflow/types.ts
index c63a2fd35..9f42f6988 100644
--- a/app/guid-node/workflow/types.ts
+++ b/app/guid-node/workflow/types.ts
@@ -62,6 +62,7 @@ export interface WorkflowRouteModel {
node: Node;
templates: WorkflowTemplate[];
pendingTemplates: PendingTemplate[];
+ providesTemplates: boolean;
apiBaseUrl: string;
templatesError?: string | null;
}
@@ -81,10 +82,16 @@ export interface WorkflowRunSummary {
isCancelling?: boolean;
}
+export interface WorkflowAssigneeUser {
+ id: string;
+ fullname: string;
+}
+
export interface WorkflowTaskSummary {
id: string;
name?: string;
assignee?: string;
+ assignee_user?: WorkflowAssigneeUser | null;
owner?: string;
created?: string;
completed?: string;
diff --git a/app/guid-node/workflow/utils.ts b/app/guid-node/workflow/utils.ts
index 694043fd9..ae2baafb5 100644
--- a/app/guid-node/workflow/utils.ts
+++ b/app/guid-node/workflow/utils.ts
@@ -1,4 +1,7 @@
-import { WorkflowTaskForm } from './types';
+import Intl from 'ember-intl/services/intl';
+import pathJoin from 'ember-osf-web/utils/path-join';
+
+import { WorkflowAssigneeUser, WorkflowTaskForm } from './types';
export function isFlowableForm(form: WorkflowTaskForm | undefined | null): boolean {
if (!form) {
@@ -13,3 +16,39 @@ export function isCedarForm(form: WorkflowTaskForm | undefined | null): boolean
}
return Boolean(form.data);
}
+
+export interface WorkflowAssigneeDisplay {
+ label: string;
+ url: string | null;
+}
+
+export function workflowAssigneeDisplay(
+ intl: Intl,
+ assignee: string | undefined | null,
+ assigneeUser: WorkflowAssigneeUser | null | undefined,
+ osfBaseUrl: string,
+): WorkflowAssigneeDisplay {
+ if (assigneeUser) {
+ return {
+ label: assigneeUser.fullname,
+ url: pathJoin(osfBaseUrl, assigneeUser.id),
+ };
+ }
+ if (!assignee) {
+ return { label: intl.t('workflow.console.tasks.dialog.unassigned') as string, url: null };
+ }
+ const lower = assignee.toLowerCase();
+ if (lower === 'executor') {
+ return { label: intl.t('workflow.console.tasks.assignee.executor') as string, url: null };
+ }
+ if (lower === 'creator') {
+ return { label: intl.t('workflow.console.tasks.assignee.creator') as string, url: null };
+ }
+ if (lower === 'manager') {
+ return { label: intl.t('workflow.console.tasks.assignee.manager') as string, url: null };
+ }
+ if (lower === 'contributor') {
+ return { label: intl.t('workflow.console.tasks.assignee.contributor') as string, url: null };
+ }
+ return { label: assignee, url: null };
+}
diff --git a/translations/en-us.yml b/translations/en-us.yml
index a3865b3e4..d9f5bc8e7 100644
--- a/translations/en-us.yml
+++ b/translations/en-us.yml
@@ -2053,6 +2053,7 @@ workflow:
assignee: 'Assignee'
created: 'Created'
due: 'Due'
+ completed: 'Completed'
status: 'Status'
actions: 'Actions'
statuses:
diff --git a/translations/ja.yml b/translations/ja.yml
index 3803341dd..fe9699004 100644
--- a/translations/ja.yml
+++ b/translations/ja.yml
@@ -2053,6 +2053,7 @@ workflow:
assignee: 担当
created: 作成
due: 期限
+ completed: 完了
status: 状態
actions: 操作
statuses:
| |