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}} >
+ + {{t 'osf-components.draft-registration-card.workflow_submitted'}} + +
+ {{/if}} {{#if this.wekoItemId}}- {{t 'osf-components.draft-registration-card.weko_id' weko_id=this.wekoItemId}} + {{t (concat 'osf-components.draft-registration-card.weko_id.' this.wekoLabelKey) weko_id=this.wekoItemId}}
{{/if}}@@ -35,7 +42,7 @@
{{t 'osf-components.draft-registration-card.form_type'}} - {{this.draftRegistration.registrationSchema.name}} + {{this.draftRegistration.registrationSchema.localizedName}}
{{t 'osf-components.draft-registration-card.started'}} diff --git a/lib/osf-components/addon/components/node-card/template.hbs b/lib/osf-components/addon/components/node-card/template.hbs index eef68cfe6..27025efa4 100644 --- a/lib/osf-components/addon/components/node-card/template.hbs +++ b/lib/osf-components/addon/components/node-card/template.hbs @@ -113,7 +113,7 @@ {{/if}}
+ local-class='DisplayText {{if this.displayTextOverride 'SubLabel'}}'> + {{~#if (eq this.itemMarker 'circle')~}}{{this.circleMarker}} {{~/if~}} {{~this.localizedDisplayText~}} {{~#if (and @isRequired (not @readonly))~}} * {{~/if~}}
+{{~#each this.itemTags as |tag|~}} + + {{~tag.localizedText~}} + {{~#if tag.info~}} +{{this.schemaBlock.helpText}}
+{{this.localizedHelpText}}
- {{t 'registries.drafts.draft.weko_id' weko_id=this.wekoItemId htmlSafe=true}} + {{t (concat 'registries.drafts.draft.weko_id.' this.wekoLabelKey) weko_id=this.wekoItemId htmlSafe=true}}
{{/if}} { + 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/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)); + }); + }); +}); 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, + ); + }); +}); diff --git a/translations/en-us.yml b/translations/en-us.yml index a70829bd0..d9f5bc8e7 100644 --- a/translations/en-us.yml +++ b/translations/en-us.yml @@ -645,7 +645,7 @@ node: metadata: new_report_modal: title: Select metadata schema - info: 'The default is 「Metadata registration of publicly funded research data」.