diff --git a/app/models/schema-response.ts b/app/models/schema-response.ts index d5562668cfa..57ae710f688 100644 --- a/app/models/schema-response.ts +++ b/app/models/schema-response.ts @@ -19,7 +19,7 @@ export default class SchemaResponseModel extends OsfModel { @attr('date') dateCreated!: Date; @attr('date') dateModified!: Date; @attr('fixstring') revisionJustification!: string; - @attr('array') revisedResponses!: string[]; + @attr('registration-response-key-array') revisedResponses!: string[]; @attr('registration-responses') revisionResponses!: RegistrationResponse; @attr('boolean') isPendingCurrentUserApproval!: boolean; diff --git a/app/packages/registration-schema/index.ts b/app/packages/registration-schema/index.ts index e6cf20fd93b..887f3f23c36 100644 --- a/app/packages/registration-schema/index.ts +++ b/app/packages/registration-schema/index.ts @@ -2,7 +2,7 @@ export { getPages } from './get-pages'; export { getSchemaBlockGroups } from './get-schema-block-group'; export { SchemaBlock, SchemaBlockType } from './schema-block'; export { SchemaBlockGroup } from './schema-block-group'; -export { buildValidation, buildMetadataValidations } from './validations'; +export { buildValidation, buildMetadataValidations, buildSchemaResponseValidations } from './validations'; export { FileReference, RegistrationResponse, diff --git a/app/packages/registration-schema/validations.ts b/app/packages/registration-schema/validations.ts index c516806b1f8..81470a8d1bd 100644 --- a/app/packages/registration-schema/validations.ts +++ b/app/packages/registration-schema/validations.ts @@ -1,7 +1,7 @@ import { assert } from '@ember/debug'; import { set } from '@ember/object'; import { ValidationObject, ValidatorFunction } from 'ember-changeset-validations'; -import { validatePresence } from 'ember-changeset-validations/validators'; +import { validateLength, validatePresence } from 'ember-changeset-validations/validators'; import DraftNode from 'ember-osf-web/models/draft-node'; import LicenseModel from 'ember-osf-web/models/license'; @@ -10,6 +10,7 @@ import NodeModel, { NodeLicense } from 'ember-osf-web/models/node'; import { RegistrationResponse } from 'ember-osf-web/packages/registration-schema'; import { SchemaBlockGroup } from 'ember-osf-web/packages/registration-schema/schema-block-group'; import { validateFileList } from 'ember-osf-web/validators/validate-response-format'; +import SchemaResponseModel from 'ember-osf-web/models/schema-response'; export const NodeLicenseFields: Record = { copyrightHolders: 'Copyright Holders', @@ -166,3 +167,22 @@ export function buildMetadataValidations() { set(validationObj, DraftMetadataProperties.NodeLicenseProperty, validateNodeLicense()); return validationObj; } + +export function buildSchemaResponseValidations() { + const validationObj: ValidationObject = {}; + const notBlank: ValidatorFunction[] = [validatePresence({ + presence: true, + ignoreBlank: true, + allowBlank: false, + allowNone: false, + type: 'blank', + })]; + set(validationObj, 'revisionJustification', notBlank); + set(validationObj, 'revisedResponses', [validateLength({ + min: 1, + allowBlank: false, + allowNone: false, + type: 'no_updated_responses', + })]); + return validationObj; +} diff --git a/app/transforms/registration-response-key-array.ts b/app/transforms/registration-response-key-array.ts new file mode 100644 index 00000000000..a9f42dc4da9 --- /dev/null +++ b/app/transforms/registration-response-key-array.ts @@ -0,0 +1,29 @@ +import Transform from '@ember-data/serializer/transform'; +import { deserializeResponseKey, serializeResponseKey } from './registration-response-key'; + +export default class RegistrationResponseKeyArrayTransform extends Transform { + deserialize(values: string[] | null): string[] { + if (values === null) { + return []; + } + return values.map( + value => deserializeResponseKey(value), + ); + } + + serialize(values: string[] | null): string[] { + if (values === null) { + return []; + } + return values.map( + value => serializeResponseKey(value), + ); + } +} + + +declare module 'ember-data/types/registries/transform' { + export default interface TransformRegistry { + 'registration-response-key-array': {}; + } // eslint-disable-line semi +} diff --git a/lib/osf-components/addon/components/registries/revised-responses-list/component.ts b/lib/osf-components/addon/components/registries/revised-responses-list/component.ts new file mode 100644 index 00000000000..7f525f7051c --- /dev/null +++ b/lib/osf-components/addon/components/registries/revised-responses-list/component.ts @@ -0,0 +1,38 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { getSchemaBlockGroups, SchemaBlockGroup } from 'ember-osf-web/packages/registration-schema'; +import SchemaResponseModel from 'ember-osf-web/models/schema-response'; +import SchemaBlockModel from 'ember-osf-web/models/schema-block'; + +interface Args { + revision: SchemaResponseModel; + schemaBlocks: SchemaBlockModel[]; +} + +export default class RevisedResponsesList extends Component { + @tracked groups?: SchemaBlockGroup[]; + + constructor(owner: unknown, args: Args) { + super(owner, args); + this.groups = getSchemaBlockGroups(this.args.schemaBlocks); + } + + get revisedResponses(): string[] { + const { revision } = this.args; + if (revision.revisedResponses) { + const allRevisedLabels = revision.revisedResponses + .reduce((labels: string[], revisedResponse: string) => { + const revisedGroup = this.groups?.filter( + (group: SchemaBlockGroup) => group.registrationResponseKey?.includes(revisedResponse), + ); + if (revisedResponse) { + labels.push(revisedGroup![0].labelBlock!.displayText!); + } + return labels; + }, []); + return allRevisedLabels; + } + return []; + } +} diff --git a/lib/osf-components/addon/components/registries/revised-responses-list/template.hbs b/lib/osf-components/addon/components/registries/revised-responses-list/template.hbs new file mode 100644 index 00000000000..b6d3a346fee --- /dev/null +++ b/lib/osf-components/addon/components/registries/revised-responses-list/template.hbs @@ -0,0 +1,9 @@ +{{~#each this.revisedResponses as |response|~}} +

+ {{~response~}} +

+{{~else}} +

+ {{~t 'registries.revisedResponsesList.noResponses'~}} +

+{{/each}} \ No newline at end of file diff --git a/lib/osf-components/addon/components/registries/revision-justification-renderer/label-display/styles.scss b/lib/osf-components/addon/components/registries/revision-justification-renderer/label-display/styles.scss new file mode 100644 index 00000000000..4c15b1a4be5 --- /dev/null +++ b/lib/osf-components/addon/components/registries/revision-justification-renderer/label-display/styles.scss @@ -0,0 +1,14 @@ +.ResponseValue { + composes: Element from '../../schema-block-renderer/styles.scss'; + margin: 10px 0 0; +} + +.ValidationErrors { + composes: Element from '../../schema-block-renderer/styles.scss'; +} + +.DisplayText { + font-size: 14px; + font-weight: 700; + margin: 20px 0 0; +} diff --git a/lib/osf-components/addon/components/registries/revision-justification-renderer/label-display/template.hbs b/lib/osf-components/addon/components/registries/revision-justification-renderer/label-display/template.hbs new file mode 100644 index 00000000000..0c69d73ff5c --- /dev/null +++ b/lib/osf-components/addon/components/registries/revision-justification-renderer/label-display/template.hbs @@ -0,0 +1,32 @@ +{{assert 'Registries::RevisionJustificationRenderer::LabelDisplay requires a revision' @revision}} +{{assert 'Registries::RevisionJustificationRenderer::LabelDisplay requires a field' @field}} +{{assert 'Registries::RevisionJustificationRenderer::LabelDisplay requires a changeset' @changeset}} +

+ {{t (concat 'registries.edit_revision.' @field)}} + {{#if @showEditButton}} + + + + {{/if}} +

+

+ {{~yield~}} +

+{{#unless @hideError}} + +{{/unless}} diff --git a/lib/osf-components/addon/components/registries/revision-justification-renderer/styles.scss b/lib/osf-components/addon/components/registries/revision-justification-renderer/styles.scss new file mode 100644 index 00000000000..3be03c7ab59 --- /dev/null +++ b/lib/osf-components/addon/components/registries/revision-justification-renderer/styles.scss @@ -0,0 +1,12 @@ +.NoResponse, +.RevisedResponses { + font-style: italic; +} + +.TextResponse { + white-space: pre-wrap; +} + +.ValidationErrors { + composes: Element from '../schema-block-renderer/styles.scss'; +} diff --git a/lib/osf-components/addon/components/registries/revision-justification-renderer/template.hbs b/lib/osf-components/addon/components/registries/revision-justification-renderer/template.hbs new file mode 100644 index 00000000000..62142c658b3 --- /dev/null +++ b/lib/osf-components/addon/components/registries/revision-justification-renderer/template.hbs @@ -0,0 +1,25 @@ +{{assert 'Registries::RevisionJustificationRenderer requires a revision' @revision}} +

{{t 'registries.edit_revision.justification.page_label'}}

+ + + {{~if @revision.revisionJustification @revision.revisionJustification (t 'registries.edit_revision.review.no_justification')~}} + + + + + diff --git a/lib/osf-components/addon/components/registries/update-dropdown/component.ts b/lib/osf-components/addon/components/registries/update-dropdown/component.ts index 8f504632a4d..4e9eed7a923 100644 --- a/lib/osf-components/addon/components/registries/update-dropdown/component.ts +++ b/lib/osf-components/addon/components/registries/update-dropdown/component.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { waitFor } from '@ember/test-waiters'; diff --git a/lib/osf-components/app/components/registries/revised-responses-list/component.js b/lib/osf-components/app/components/registries/revised-responses-list/component.js new file mode 100644 index 00000000000..317dcebd98a --- /dev/null +++ b/lib/osf-components/app/components/registries/revised-responses-list/component.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/registries/revised-responses-list/component'; diff --git a/lib/osf-components/app/components/registries/revised-responses-list/template.js b/lib/osf-components/app/components/registries/revised-responses-list/template.js new file mode 100644 index 00000000000..3930a8eb60b --- /dev/null +++ b/lib/osf-components/app/components/registries/revised-responses-list/template.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/registries/revised-responses-list/template'; diff --git a/lib/osf-components/app/components/registries/revision-justification-renderer/label-display/template.js b/lib/osf-components/app/components/registries/revision-justification-renderer/label-display/template.js new file mode 100644 index 00000000000..34128155dd5 --- /dev/null +++ b/lib/osf-components/app/components/registries/revision-justification-renderer/label-display/template.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/registries/revision-justification-renderer/label-display/template'; diff --git a/lib/osf-components/app/components/registries/revision-justification-renderer/template.js b/lib/osf-components/app/components/registries/revision-justification-renderer/template.js new file mode 100644 index 00000000000..48b96afac66 --- /dev/null +++ b/lib/osf-components/app/components/registries/revision-justification-renderer/template.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/registries/revision-justification-renderer/template'; diff --git a/lib/registries/addon/components/page-link/component.ts b/lib/registries/addon/components/page-link/component.ts index ec7a811fded..6ec33df0f0d 100644 --- a/lib/registries/addon/components/page-link/component.ts +++ b/lib/registries/addon/components/page-link/component.ts @@ -55,7 +55,7 @@ export default class PageLinkComponent extends Component { } return PageState.Invalid; } - if (this.pageName === 'metadata') { + if (this.pageName === 'metadata' || this.pageName === 'justification') { if (this.metadataIsValid) { return PageState.Valid; } diff --git a/lib/registries/addon/edit-revision/-components/right-nav/template.hbs b/lib/registries/addon/edit-revision/-components/right-nav/template.hbs index d472b01acce..f370bef96d4 100644 --- a/lib/registries/addon/edit-revision/-components/right-nav/template.hbs +++ b/lib/registries/addon/edit-revision/-components/right-nav/template.hbs @@ -51,6 +51,21 @@ {{/if}} +{{#if @navManager.isFirstPage}} + + + +{{/if}} {{#if @revisionManager.lastSaveFailed}} diff --git a/lib/registries/addon/edit-revision/-components/top-nav/template.hbs b/lib/registries/addon/edit-revision/-components/top-nav/template.hbs index 71c9e882c00..acdfe7f4e8c 100644 --- a/lib/registries/addon/edit-revision/-components/top-nav/template.hbs +++ b/lib/registries/addon/edit-revision/-components/top-nav/template.hbs @@ -4,7 +4,7 @@ - {{#if (and (not @revisionManager.currentUserIsReadOnly) @revisionManager.isEditable)}} + {{#if @navManager.showSidenav}} {{#if @navManager.inReview}} {{t 'registries.drafts.draft.review.page_label'}} + {{else if @navManager.inJustification}} + {{t 'registries.edit_revision.justification.page_label'}} {{else}} {{@navManager.currentPageManager.pageHeadingText}} {{/if}} - {{#if (and @navManager.prevPageParam (not @revisionManager.currentUserIsReadOnly) @revisionManager.isEditable)}} + {{#if @navManager.isFirstPage}} + + {{t 'registries.edit_revision.justification.page_label'}} + + {{/if}} + {{#if @navManager.showPreviousButton}} +{{else}} +
+

+ {{t 'registries.edit_revision.justification.title'}} +

+

+ {{t 'registries.edit_revision.justification.justification_description'}} +

+
+
+ + {{#let (unique-id 'reason') as |reasonFieldId|}} + + + {{/let}} + {{#let (unique-id 'questions') as |questionsFieldId|}} + + + {{/let}} + +
+
+{{/if}} \ No newline at end of file diff --git a/lib/registries/addon/edit-revision/nav-manager.ts b/lib/registries/addon/edit-revision/nav-manager.ts index fb6ff41bac3..7c596ab91ea 100644 --- a/lib/registries/addon/edit-revision/nav-manager.ts +++ b/lib/registries/addon/edit-revision/nav-manager.ts @@ -7,6 +7,7 @@ import { getNextPageParam, getPageSlug, getPrevPageParam } from 'ember-osf-web/u import RevisionManager from 'registries/edit-revision/revision-manager'; export enum RevisionRoute { + Justification = 'justification', Page = 'page', Review = 'review', } @@ -19,6 +20,9 @@ export default class RevisionNavigationManager { @alias('revisionManager.pageManagers') pageManagers!: PageManager[]; + @equal('currentRoute', RevisionRoute.Justification) + inJustification!: boolean; + @equal('currentRoute', RevisionRoute.Review) inReview!: boolean; @@ -37,6 +41,17 @@ export default class RevisionNavigationManager { setProperties(this, { currentRoute, currentPage }); } + @computed('revisionManager.{currentUserIsReadOnly,isEditable}') + get showSidenav() { + return this.revisionManager.isEditable && !this.revisionManager.currentUserIsReadOnly; + } + + @computed('prevPageParam', 'revisionManager.{currentUserIsReadOnly,isEditable}') + get showPreviousButton() { + const { prevPageParam, revisionManager } = this; + return prevPageParam && revisionManager.isEditable && !revisionManager.currentUserIsReadOnly; + } + @computed('currentPage', 'lastPage') get isLastPage() { return this.currentPage === this.lastPage; @@ -48,16 +63,22 @@ export default class RevisionNavigationManager { return pageManagers.length - 1; } - @computed('currentPage', 'pageManagers.[]', 'lastPage') + @computed('currentPage', 'pageManagers.[]', 'inJustification', 'lastPage') get nextPageParam() { const { pageManagers, currentPage, lastPage, + inJustification, } = this; if (!isEmpty(pageManagers)) { let pageHeadingText; + if (inJustification) { + [{ pageHeadingText }] = pageManagers; + return getPageSlug(1, pageHeadingText); + } + if (typeof currentPage !== 'undefined' && (currentPage < lastPage)) { ({ pageHeadingText } = pageManagers[currentPage + 1]); return getNextPageParam(currentPage, pageHeadingText); diff --git a/lib/registries/addon/edit-revision/page/controller.ts b/lib/registries/addon/edit-revision/page/controller.ts index aa7d02dc41d..e749bebfbda 100644 --- a/lib/registries/addon/edit-revision/page/controller.ts +++ b/lib/registries/addon/edit-revision/page/controller.ts @@ -10,7 +10,7 @@ import NavigationManager from 'registries/drafts/draft/navigation-manager'; import RevisionManager from 'registries/edit-revision/revision-manager'; import { RevisionPageRouteModel } from './route'; -export default class RegistriesDraftPage extends Controller { +export default class EditRevisionPage extends Controller { @service router!: RouterService; model!: RevisionPageRouteModel; @@ -29,6 +29,7 @@ export default class RegistriesDraftPage extends Controller { this.replaceRoute('edit-revision.page', draftId, pageSlug); } this.revisionManager.onPageChange(pageIndex); + this.revisionManager.revisionChangeset.validate(); } @action diff --git a/lib/registries/addon/edit-revision/review/route.ts b/lib/registries/addon/edit-revision/review/route.ts index f8124d89d91..929e2f6cb62 100644 --- a/lib/registries/addon/edit-revision/review/route.ts +++ b/lib/registries/addon/edit-revision/review/route.ts @@ -7,7 +7,7 @@ import { RevisionRoute } from 'registries/edit-revision/nav-manager'; import { EditRevisionRouteModel } from '../route'; -export default class DraftRegistrationReview extends Route { +export default class EditRevisionReview extends Route { @service analytics!: Analytics; model(): EditRevisionRouteModel { diff --git a/lib/registries/addon/edit-revision/review/template.hbs b/lib/registries/addon/edit-revision/review/template.hbs index 596be95f4ae..f756f6dfd5b 100644 --- a/lib/registries/addon/edit-revision/review/template.hbs +++ b/lib/registries/addon/edit-revision/review/template.hbs @@ -28,6 +28,12 @@ @showMetadata={{true}} @schemaBlocks={{revisionManager.schemaBlocks}} /> + pageManager.pageIsValid); + return this.pageManagers.every(pageManager => pageManager.pageIsValid) + && this.revisionIsValid; } - @computed('onPageInput.lastComplete') + @dependentKeyCompat + get revisionIsValid() { + return this.revisionChangeset.isValid; + } + + @computed('onPageInput.lastComplete', 'updateRevisionAndSave.lastComplete') get lastSaveFailed() { const onPageInputLastComplete = taskFor(this.onPageInput).lastComplete; + const updateRevisionAndSaveLastComplete = taskFor(this.updateRevisionAndSave).lastComplete; const pageInputFailed = onPageInputLastComplete ? onPageInputLastComplete.isError : false; - return pageInputFailed; + const updateRevisionAndSaveFailed = updateRevisionAndSaveLastComplete + ? updateRevisionAndSaveLastComplete.isError : false; + return pageInputFailed || updateRevisionAndSaveFailed; } @computed('revision.reviewsState') @@ -92,6 +108,7 @@ export default class RevisionManager { set(this, 'loadModelsTask', loadModelsTask); set(this, 'revisionId', revisionId); taskFor(this.initializePageManagers).perform(); + taskFor(this.initializeRevisionChangeset).perform(); } @restartableTask @@ -165,9 +182,30 @@ export default class RevisionManager { set(this, 'pageManagers', pageManagers); } + @task + @waitFor + async initializeRevisionChangeset() { + const { revision } = await this.loadModelsTask; + if (!revision) { + return this.router.transitionTo('registries.page-not-found', window.location.href.slice(-1)); + } + const revisionValidations = buildSchemaResponseValidations(); + const revisionChangeset = buildChangeset(revision, revisionValidations); + set(this, 'revisionChangeset', revisionChangeset); + } + + @restartableTask + @waitFor + async onJustificationInput() { + await timeout(5000); // debounce + await taskFor(this.updateRevisionAndSave).perform(); + } + @restartableTask @waitFor - async updateDraftRegistrationAndSave() { + async updateRevisionAndSave() { + const { revisionChangeset, revision } = this; + set(revision, 'revisionJustification', revisionChangeset.get('revisionJustification')); try { await this.revision.save(); } catch (e) { @@ -204,6 +242,7 @@ export default class RevisionManager { @action validateAllVisitedPages() { + this.revisionChangeset.validate(); this.visitedPages .forEach(pageManager => { pageManager.changeset!.validate(); diff --git a/lib/registries/addon/edit-revision/route.ts b/lib/registries/addon/edit-revision/route.ts index 9599816e7e3..03c166018f3 100644 --- a/lib/registries/addon/edit-revision/route.ts +++ b/lib/registries/addon/edit-revision/route.ts @@ -10,6 +10,7 @@ import { taskFor } from 'ember-concurrency-ts'; import requireAuth from 'ember-osf-web/decorators/require-auth'; import { RevisionReviewStates } from 'ember-osf-web/models/schema-response'; import Analytics from 'ember-osf-web/services/analytics'; +import captureException from 'ember-osf-web/utils/capture-exception'; import RevisionNavigationManager from 'registries/edit-revision/nav-manager'; import RevisionManager from 'registries/edit-revision/revision-manager'; @@ -27,18 +28,23 @@ export default class EditRevisionRoute extends Route { @task @waitFor async loadModels(revisionId: string) { - const revision = await this.store.findRecord('schema-response', revisionId); - const registration = await revision.registration; - const provider = await registration.provider; - if ((registration.currentUserIsReadOnly && revision.reviewsState !== RevisionReviewStates.Approved) || - (revision.reviewsState !== RevisionReviewStates.RevisionInProgress)) { - this.replaceWith('edit-revision.review', revisionId); + try { + const revision = await this.store.findRecord('schema-response', revisionId); + const registration = await revision.registration; + const provider = await registration.provider; + if ((registration.currentUserIsReadOnly && revision.reviewsState !== RevisionReviewStates.Approved) || + (revision.reviewsState !== RevisionReviewStates.RevisionInProgress)) { + this.replaceWith('edit-revision.review', revisionId); + } + return { + revision, + registration, + provider, + }; + } catch (error) { + captureException(error); + return this.transitionTo('page-not-found', window.location.href.slice(-1)); } - return { - revision, - registration, - provider, - }; } model(params: { revisionId: string }) { diff --git a/lib/registries/addon/edit-revision/template.hbs b/lib/registries/addon/edit-revision/template.hbs index f1b02a718b1..77cbbdd2a7f 100644 --- a/lib/registries/addon/edit-revision/template.hbs +++ b/lib/registries/addon/edit-revision/template.hbs @@ -45,6 +45,16 @@ {{/if}} {{#if (not (or revisionManager.currentUserIsReadOnly (not revisionManager.isEditable)))}} + {{#each revisionManager.pageManagers as |pageManager index|}} { // check leftnav const reviewNav = find('[data-test-link="review"]'); + assert.dom('[data-test-link="justification"]') + .doesNotExist('Leftnav: Label for justification page is not shown'); assert.dom('[data-test-link="1-first-page-of-test-schema"]') .doesNotExist('Leftnav: Label for first page is not shown'); assert.dom('[data-test-link="review"]').exists('Leftnav: Review label shown'); @@ -102,7 +104,7 @@ module('Registries | Acceptance | registries revision', hooks => { await percySnapshot('Read-only Revision Review page: Mobile'); }); - test('it redirects to the first page of the revision form', async function(this: RevisionTestContext, assert) { + test('it redirects to the justification page of revision form', async function(this: RevisionTestContext, assert) { const initiatedBy = server.create('user', 'loggedIn'); const revision = server.create( 'schema-response', @@ -113,7 +115,7 @@ module('Registries | Acceptance | registries revision', hooks => { }, ); await visit(`/registries/revisions/${revision.id}/`); - assert.equal(currentRouteName(), 'registries.edit-revision.page', 'At the expected route'); + assert.equal(currentRouteName(), 'registries.edit-revision.justification', 'At the expected route'); }); test('left nav controls', async function(this: RevisionTestContext, assert) { @@ -127,10 +129,27 @@ module('Registries | Acceptance | registries revision', hooks => { ); await visit(`/registries/revisions/${revision.id}/`); - await percySnapshot('Registries | Acceptance | registries revision | left nav controls | first page'); + await percySnapshot('Registries | Acceptance | registries revision | left nav controls | justification page'); + + // justification page + assert.equal(currentRouteName(), 'registries.edit-revision.justification', 'Starts at justification page'); + assert.dom('[data-test-link="justification"] > [data-test-icon]') + .hasClass('fa-dot-circle', 'justification page is current page'); + assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') + .hasClass('fa-circle', 'page 1 is marked unvisited'); + assert.dom('[data-test-link="review"] > [data-test-icon]') + .hasClass('fa-circle', 'review is marked unvisited'); + assert.dom('[data-test-goto-previous-page]').doesNotExist(); + assert.dom('[data-test-goto-next-page]').isVisible(); + assert.dom('[data-test-goto-review]').doesNotExist(); + assert.dom('[data-test-submit-revision]').doesNotExist(); // first page + await click('[data-test-link="1-first-page-of-test-schema"]'); + await percySnapshot('Registries | Acceptance | registries revision | left nav controls | first page'); assert.equal(currentRouteName(), 'registries.edit-revision.page', 'Starts at first page'); + assert.dom('[data-test-link="justification"] > [data-test-icon]') + .hasClass('fa-exclamation-circle', 'justification page is marked visited, invalid'); assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') .hasClass('fa-dot-circle', 'page 1 is current page'); assert.dom('[data-test-link="2-this-is-the-second-page"] > [data-test-icon]') @@ -142,10 +161,11 @@ module('Registries | Acceptance | registries revision', hooks => { assert.dom('[data-test-goto-review]').doesNotExist(); assert.dom('[data-test-submit-revision]').doesNotExist(); - // Navigate to second page await click('[data-test-link="2-this-is-the-second-page"]'); await percySnapshot('Registries | Acceptance | registries revision | left nav controls | second page'); assert.equal(currentRouteName(), 'registries.edit-revision.page', 'Goes to second page'); + assert.dom('[data-test-link="justification"] > [data-test-icon]') + .hasClass('fa-exclamation-circle', 'justification page is marked visited, invalid'); assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') .hasClass('fa-exclamation-circle', 'page 1 is marked visited, invalid'); assert.dom('[data-test-link="2-this-is-the-second-page"] > [data-test-icon]') @@ -174,6 +194,8 @@ module('Registries | Acceptance | registries revision', hooks => { await click('[data-test-link="review"]'); await percySnapshot('Registries | Acceptance | registries revision | left nav controls | review page'); assert.equal(currentRouteName(), 'registries.edit-revision.review', 'Goes to review route'); + assert.dom('[data-test-link="justification"] > [data-test-icon]') + .hasClass('fa-exclamation-circle', 'justification page is marked visited, invalid'); assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') .hasClass('fa-exclamation-circle', 'page 1 is marked visited, invalid'); assert.dom('[data-test-link="2-this-is-the-second-page"] > [data-test-icon]') @@ -199,12 +221,26 @@ module('Registries | Acceptance | registries revision', hooks => { await visit(`/registries/revisions/${revision.id}/`); + + // Justification page + assert.equal(currentRouteName(), 'registries.edit-revision.justification', 'At justification page'); + assert.dom('[data-test-submit-revision]').doesNotExist(); + assert.dom('[data-test-goto-previous-page]').doesNotExist(); + assert.dom('[data-test-goto-justification]').doesNotExist(); + + assert.dom('[data-test-goto-next-page]').exists(); + assert.ok(getHrefAttribute('[data-test-goto-next-page]')! + .includes(`/registries/revisions/${revision.id}/1-`)); + + await click('[data-test-goto-next-page]'); + // First page of form assert.ok(currentURL().includes(`/registries/revisions/${revision.id}/1-`), 'At first schema page'); assert.dom('[data-test-submit-revision]').doesNotExist(); assert.dom('[data-test-goto-previous-page]').doesNotExist(); + assert.dom('[data-test-goto-justification]').exists(); assert.dom('[data-test-goto-next-page]').exists(); assert.ok(getHrefAttribute('[data-test-goto-next-page]')! .includes(`/registries/revisions/${revision.id}/2-`)); @@ -219,6 +255,7 @@ module('Registries | Acceptance | registries revision', hooks => { assert.dom('[data-test-submit-revision]').doesNotExist(); assert.dom('[data-test-goto-next-page]').doesNotExist(); + assert.dom('[data-test-goto-justification]').doesNotExist(); assert.dom('[data-test-goto-review]').isVisible(); assert.ok(getHrefAttribute('[data-test-goto-review]')! @@ -232,6 +269,7 @@ module('Registries | Acceptance | registries revision', hooks => { assert.dom('data-test-goto-next-page').doesNotExist(); assert.dom('[data-test-goto-review]').doesNotExist(); + assert.dom('[data-test-goto-justification]').doesNotExist(); assert.dom('[data-test-submit-revision]').isVisible(); assert.dom('[data-test-nonadmin-warning-text]').doesNotExist('Warning for non-admins not shown to admins'); @@ -257,21 +295,34 @@ module('Registries | Acceptance | registries revision', hooks => { setBreakpoint('mobile'); - assert.ok(currentURL().includes(`/registries/revisions/${revision.id}/1-`), 'At first page'); - await percySnapshot('Registries | Acceptance | registries revisions | mobile navigation | first page'); + + // Justification page + assert.equal(currentRouteName(), 'registries.edit-revision.justification', 'At justification page'); + await percySnapshot('Registries | Acceptance | registries revision | mobile nav controls | justification page'); + assert.dom('[data-test-page-label]').containsText('Justification'); + assert.dom('[data-test-goto-previous-page]').isNotVisible(); + assert.dom('[data-test-goto-next-page]').isVisible(); + assert.dom('[data-test-submit-revision]').doesNotExist(); + assert.dom('[data-test-goto-justification]').doesNotExist(); + await click('[data-test-goto-next-page]'); // First page + assert.ok(currentURL().includes(`/registries/revisions/${revision.id}/1-`), 'At first page'); + await percySnapshot('Registries | Acceptance | registries revisions | mobile navigation | first page'); assert.dom('[data-test-page-label]').containsText('First page'); assert.dom('[data-test-goto-previous-page]').isNotVisible(); assert.dom('[data-test-goto-next-page]').isVisible(); + assert.dom('[data-test-goto-justification]').exists(); assert.dom('[data-test-submit-revision]').doesNotExist(); await click('[data-test-goto-next-page]'); + // Second page await percySnapshot('Registries | Acceptance | registries revisions | mobile navigation | second page'); assert.dom('[data-test-page-label]').containsText('This is the second page'); assert.dom('[data-test-goto-previous-page]').isVisible(); assert.dom('[data-test-goto-next-page]').isNotVisible(); assert.dom('[data-test-goto-review]').isVisible(); + assert.dom('[data-test-goto-justification]').doesNotExist(); assert.dom('[data-test-submit-revision]').doesNotExist(); // Review page @@ -302,14 +353,22 @@ module('Registries | Acceptance | registries revision', hooks => { { initiatedBy, registration: this.registration, + revisionResponses: {}, }, ); await visit(`/registries/revisions/${revision.id}`); + assert.equal(currentRouteName(), 'registries.edit-revision.justification', 'At justification page'); + assert.dom('[data-test-validation-errors="revisionJustification"]') + .doesNotExist('No validation errors on first load'); + await click('[data-test-goto-next-page]'); + // check first page is not invalid assert.ok(currentURL().includes(`/registries/revisions/${revision.id}/1-`), 'At first page'); + assert.dom('[data-test-link="justification"] > [data-test-icon]') + .hasClass('fa-exclamation-circle', 'justification page is marked visited, invalid'); assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') .hasClass('fa-dot-circle', 'on page 1'); // NOTE: the validation errors are shown if we enter the first page having done either the @@ -317,13 +376,16 @@ module('Registries | Acceptance | registries revision', hooks => { // or the controller.replaceRoute from edit-revision.page (/revisions/:id/:page) // Validation errors are not shown if entering with the pageslug appended (/revisions/:id/:page-page-slug) assert.dom(`[data-test-validation-errors="${deserializeResponseKey('page-one_short-text')}"]`) - .exists('Validation message shown on initial load'); + .doesNotExist('Validation message not shown on initial load'); await click('[data-test-link="review"]'); assert.equal(currentRouteName(), 'registries.edit-revision.review', 'At review page'); assert.dom('[data-test-submit-revision]').isDisabled('Submit button disabled'); assert.dom('[data-test-nonadmin-warning-text]').doesNotExist('Warning for non-admins not shown to admins'); assert.dom('[data-test-invalid-responses-text]').isVisible('Invalid response text shown'); + + assert.dom('[data-test-validation-errors="revisionJustification"]').exists('justification invalid'); + assert.dom('[data-test-validation-errors="revisedResponses"]').exists('revised responses invalid'); assert.dom(`[data-test-validation-errors="${deserializeResponseKey('page-one_short-text')}"]`) .exists('short text invalid'); assert.dom(`[data-test-validation-errors="${deserializeResponseKey('page-one_long-text')}"]`) @@ -331,6 +393,8 @@ module('Registries | Acceptance | registries revision', hooks => { assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') .hasClass('fa-exclamation-circle', 'first page invalid'); + // hack since we don't actually track which fields have been updated in mirage + revision.update({ revisedResponses: ['page-one_short-text'] }); // check the first page and correct invalid answer await click('[data-test-link="1-first-page-of-test-schema"]'); assert.ok(currentURL().includes(`/registries/revisions/${revision.id}/1-`), @@ -341,10 +405,20 @@ module('Registries | Acceptance | registries revision', hooks => { assert.dom(`[data-test-validation-errors="${deserializeResponseKey('page-one_short-text')}"]`) .doesNotExist('short text valid after being filled'); + // check justification page and fix invalid answer + await click('[data-test-link="justification"]'); + await fillIn('textarea[name="revisionJustification"]', 'Tell the world that ditto is the best'); + assert.dom('[data-test-validation-errors="revisionJustification"]').doesNotExist('justification valid'); + assert.dom('[data-test-revised-responses-list]').exists({ count: 1 }, 'revised responses list shown'); + // check the review page to see if text is valid again await click('[data-test-link="review"]'); + assert.dom('[data-test-link="justification"] > [data-test-icon]') + .hasClass('fa-check-circle', 'justification page is now valid'); assert.dom('[data-test-link="1-first-page-of-test-schema"] > [data-test-icon]') .hasClass('fa-check-circle', 'first page now valid'); + assert.dom('[data-test-validation-errors="revisionJustification"]').doesNotExist('justification valid'); + assert.dom('[data-test-validation-errors="revisedResponses"]').doesNotExist('revised responses valid'); assert.dom(`[data-test-validation-errors="${deserializeResponseKey('page-one_short-text')}"]`) .doesNotExist('short text now valid'); assert.dom('[data-test-submit-revision]').isNotDisabled('Submit button no longer disabled'); @@ -360,6 +434,8 @@ module('Registries | Acceptance | registries revision', hooks => { 'page-one_short-text': 'Pekatyu', }, registration: this.registration, + revisedResponses: ['page-one_short-text'], + revisionJustification: 'If pikachu were russian', }, ); diff --git a/tests/integration/components/registries/revised-responses-list/component-test.ts b/tests/integration/components/registries/revised-responses-list/component-test.ts new file mode 100644 index 00000000000..a14bff35d43 --- /dev/null +++ b/tests/integration/components/registries/revised-responses-list/component-test.ts @@ -0,0 +1,57 @@ +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupIntl, t, TestContext } from 'ember-intl/test-support'; +import { setupRenderingTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import SchemaBlockModel from 'ember-osf-web/models/schema-block'; +import SchemaResponseModel from 'ember-osf-web/models/schema-response'; + +interface ThisTestContext extends TestContext { + blocks?: SchemaBlockModel[]; + revision: SchemaResponseModel; +} + +module('Integration | Component | revised-responses-list', hooks => { + setupRenderingTest(hooks); + setupIntl(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function(this: ThisTestContext) { + this.store = this.owner.lookup('service:store'); + server.loadFixtures('schema-blocks'); + server.loadFixtures('registration-schemas'); + const registrationSchema = server.schema.registrationSchemas.find('testSchema'); + const registration = server.create('registration', { registrationSchema }); + const revisionModel = server.create('schema-response', { + registration, + registrationSchema, + }); + const revision = await this.store.findRecord('schema-response', revisionModel.id); + const schema = await revision.registrationSchema; + this.revision = revision; + this.blocks = await schema.loadAll('schemaBlocks'); + }); + + test('no revised responses', async function(this: ThisTestContext, assert) { + await render(hbs` + `); + assert.dom('[data-test-revised-responses-list]').doesNotExist('Updated responses are not shown'); + assert.dom('[data-test-revised-responses-list-no-update]') + .containsText(t('registries.revisedResponsesList.noResponses'), 'No responses updated message shown'); + }); + + test('multiple revised responses', async function(this: ThisTestContext, assert) { + this.revision.revisedResponses = ['page-one_single-select', 'page-one_short-text']; + await render(hbs``); + assert.dom('[data-test-revised-responses-list-no-update]') + .doesNotExist('No response updated message not shown'); + assert.dom('[data-test-revised-responses-list]') + .exists({ count: 2 }, 'Responsese updated message shown appropriate number of times'); + }); +}); diff --git a/tests/unit/transforms/registration-responses-key-array-test.ts b/tests/unit/transforms/registration-responses-key-array-test.ts new file mode 100644 index 00000000000..e5532a7138b --- /dev/null +++ b/tests/unit/transforms/registration-responses-key-array-test.ts @@ -0,0 +1,57 @@ +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import { responseKeyPrefix } from 'ember-osf-web/transforms/registration-response-key'; +import RegistrationResponseKeyArrayTransform from 'ember-osf-web/transforms/registration-response-key-array'; + +module('Unit | Transform | registration-responses', hooks => { + setupTest(hooks); + + test('deserializes', function(assert) { + const transform: RegistrationResponseKeyArrayTransform = this.owner.lookup( + 'transform:registration-response-key-array', + ); + + assert.deepEqual( + transform.deserialize([ 'foo', 'bar' ]), + [`${responseKeyPrefix}foo`, `${responseKeyPrefix}bar`], + 'adds response key prefix to all members of array', + ); + + assert.deepEqual( + transform.deserialize(['foo.bar', 'foo.baz']), + [`${responseKeyPrefix}foo|bar`, `${responseKeyPrefix}foo|baz`], + 'adds response key prefix and transforms dots to pipes for all members of array', + ); + + assert.deepEqual( + transform.deserialize(null), + [], + 'transforms null to empty array', + ); + }); + + test('serialize', function(assert) { + const transform: RegistrationResponseKeyArrayTransform = this.owner.lookup( + 'transform:registration-response-key-array', + ); + + assert.deepEqual( + transform.serialize([`${responseKeyPrefix}foo`, `${responseKeyPrefix}baz`]), + ['foo', 'baz'], + 'removes response key prefix for all members of array', + ); + + assert.deepEqual( + transform.serialize([`${responseKeyPrefix}foo|bar`, `${responseKeyPrefix}foo|baz`]), + ['foo.bar', 'foo.baz'], + 'removes response key prefix and transforms pipes to dots for all members of array', + ); + + assert.deepEqual( + transform.serialize(null), + [], + 'transforms null to empty array', + ); + }); +}); diff --git a/translations/en-us.yml b/translations/en-us.yml index f1dd37ef598..6ba6a9a5411 100644 --- a/translations/en-us.yml +++ b/translations/en-us.yml @@ -525,6 +525,7 @@ validationErrors: onlyProjectOrComponentFiles: 'The {numOfFiles, plural, =1 {file} other {files}} "{missingFilesList}" cannot be found on this {projectOrComponent}.' new_folder_name: 'Folder name must not be blank.' year_format: 'Please specify a valid year.' + no_updated_responses: 'No changes have been made in this update.' validated_input_form: discard_changes: 'Discard changes' node_navbar: @@ -1112,8 +1113,17 @@ registries: unable_to_fetch_children_count: 'Unable to fetch project''s components' submit_error: 'Unable to create a registration.' edit_revision: + revisionJustification: 'Justification for update' + revisedResponses: 'List of updated registration questions' + justification: + page_label: Justification + title: 'Justification for Update' + justification_label: 'Explain the updates made to this registration and why the changes were necessary. Be thorough in your response.' + justification_description: 'Justification will be provided to admins and moderators upon review. Once approved, it will be displayed at the top of the registration.' page_title: 'Update registration' review: + justification_page_label: 'Justification' + no_justification: 'No justification provided.' submit_changes: 'Submit changes' accept_changes: 'Accept changes' continue_editing: 'Continue editing' @@ -1438,6 +1448,8 @@ registries: submit: Submit back: Back datePlaceholder: 'Choose embargo end date' + revisedResponsesList: + noResponses: 'No responses updated' sharingIcons: label: 'Share this registration via {mediaType}' makeDecisionDropdown: @@ -1726,6 +1738,7 @@ osf-components: collapseSideNav: 'Collapse registration form navigation' registries-top-nav: showRegistrationNavigation: 'Show registration form navigation' + justification: 'Return to justification' metadata: 'Return to metadata' previousPage: 'Previous page' nextPage: 'Next page'