diff --git a/web/app/components/document/modal.hbs b/web/app/components/document/modal.hbs index 2ab808351..72ce6e910 100644 --- a/web/app/components/document/modal.hbs +++ b/web/app/components/document/modal.hbs @@ -35,13 +35,15 @@ disabled={{or @taskButtonIsDisabled this.taskIsRunning}} {{on "click" (perform this.task)}} /> - + {{#unless @secondaryButtonIsHidden}} + + {{/unless}} {{/if}} diff --git a/web/app/components/document/modal.ts b/web/app/components/document/modal.ts index 213c22f1e..59be014aa 100644 --- a/web/app/components/document/modal.ts +++ b/web/app/components/document/modal.ts @@ -17,8 +17,9 @@ interface DocumentModalComponentSignature { taskButtonIsDisabled?: boolean; hideFooterWhileSaving?: boolean; color?: HdsModalColor; + secondaryButtonIsHidden?: boolean; close: () => void; - task?: () => Promise | void; + task?: (newOwner?: string) => Promise | void; }; Blocks: { default: [{ taskIsRunning: boolean }]; @@ -69,6 +70,15 @@ export default class DocumentModalComponent extends Component {{/if}} {{/each-in}} + + {{! Transfer ownership }} + {{#if this.isOwner}} +
+
+ + + Transfer ownership... + +
+
+ {{/if}} + {{#if this.footerIsShown}} @@ -605,6 +623,109 @@ /> {{/if}} + {{! Transfer ownership }} + {{#if this.transferOwnershipModalIsShown}} + + + <:default as |M|> + {{#if M.taskIsRunning}} +
+ +

Transferring doc...

+
+ {{else}} + +

+ Give this document to someone in your workspace. +
+ We'll notify them when the transfer completes. +

+ +
+
+ + +
+
+ +
+
+ {{/if}} + +
+
+ {{/if}} + + {{! Ownership transferred }} + {{#if this.ownershipTransferredModalIsShown}} + + +
+ + Done +
+
+ +
+

Ownership transferred.

+

+ {{get-model-attr "person.name" (get @document.owners 0)}} + has been notified of the change. +

+
+
+ + + +
+ {{/if}} + {{#if this.requestReviewModalIsShown}} { + patchDocument = enqueueTask(async (fields: any, throwOnError?: boolean) => { const endpoint = this.isDraft ? "drafts" : "documents"; try { @@ -711,6 +813,14 @@ export default class DocumentSidebarComponent extends Component { + assert("owner must exist", this.newOwners.length > 0); + + try { + await this.patchDocument.perform( + { + owners: this.newOwners, + }, + true, + ); + + this.transferOwnershipModalIsShown = false; + this.ownershipTransferredModalIsShown = true; + this.newOwners = []; + } catch (error) { + const e = error as Error; + this.maybeLockDoc(e); + + // trigger the modal error + throw e; + } + }); + @action updateApprovers(approvers: string[]) { this.approvers = approvers; } diff --git a/web/app/components/inputs/people-select.hbs b/web/app/components/inputs/people-select.hbs index 7a685266f..b6d663595 100644 --- a/web/app/components/inputs/people-select.hbs +++ b/web/app/components/inputs/people-select.hbs @@ -1,6 +1,6 @@ diff --git a/web/app/components/inputs/people-select.ts b/web/app/components/inputs/people-select.ts index 67ae69c4a..c39c51343 100644 --- a/web/app/components/inputs/people-select.ts +++ b/web/app/components/inputs/people-select.ts @@ -9,9 +9,9 @@ import Ember from "ember"; import StoreService from "hermes/services/store"; import PersonModel from "hermes/models/person"; import { Select } from "ember-power-select/components/power-select"; -import { next } from "@ember/runloop"; +import { next, schedule } from "@ember/runloop"; import calculatePosition from "ember-basic-dropdown/utils/calculate-position"; -import { assert } from "@ember/debug"; +import AuthenticatedUserService from "hermes/services/authenticated-user"; export interface GoogleUser { emailAddresses: { value: string }[]; @@ -47,6 +47,25 @@ interface InputsPeopleSelectComponentSignature { renderInPlace?: boolean; disabled?: boolean; onKeydown?: (dropdown: any, event: KeyboardEvent) => void; + + /** + * Whether the dropdown should be single-select. + * When true, will not show the dropdown when there's a selection. + */ + isSingleSelect?: boolean; + + /** + * Whether to exclude the authenticated user from the dropdown. + * Used by the "Transfer ownership" modal, where suggesting the + * authenticated user as a new owner would be unhelpful. + */ + excludeSelf?: boolean; + + /** + * The ID of the EmberPowerSelect trigger. Allows the label + * to be associated with the input for accessibility purposes. + */ + triggerId?: string; }; } @@ -56,6 +75,7 @@ const INITIAL_RETRY_DELAY = Ember.testing ? 0 : 500; export default class InputsPeopleSelectComponent extends Component { @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; + @service declare authenticatedUser: AuthenticatedUserService; @service declare store: StoreService; /** @@ -80,6 +100,28 @@ export default class InputsPeopleSelectComponent extends Component { + dropdown.actions.close(); + }); + break; + default: + if (this.args.onKeydown) { + this.args.onKeydown(dropdown, event); + } + } + } + /** * An action occurring on every keystroke. * Handles cases where the user clears the input, @@ -107,6 +149,16 @@ export default class InputsPeopleSelectComponent extends Component 0) { + select.actions.close(); + } + } /** * Custom position-calculating function for the dropdown. @@ -129,31 +181,31 @@ export default class InputsPeopleSelectComponent extends Component selectedEmail === email, ); + }) + .filter((email: string) => { + // filter the authenticated user if `excludeSelf` is true + return ( + !this.args.excludeSelf || + email !== this.authenticatedUser.info.email + ); }); } else { this.people = []; diff --git a/web/app/components/type-to-confirm.hbs b/web/app/components/type-to-confirm.hbs new file mode 100644 index 000000000..d47fb5a95 --- /dev/null +++ b/web/app/components/type-to-confirm.hbs @@ -0,0 +1,13 @@ +{{yield + (hash + Input=(component + "type-to-confirm/input" + id=this.id + value=@value + inputValue=this.inputValue + onInput=this.onInput + onKeydown=this.onKeydown + ) + hasConfirmed=this.hasConfirmed + ) +}} diff --git a/web/app/components/type-to-confirm.ts b/web/app/components/type-to-confirm.ts new file mode 100644 index 000000000..4a379b7a7 --- /dev/null +++ b/web/app/components/type-to-confirm.ts @@ -0,0 +1,93 @@ +import Component from "@glimmer/component"; +import { guidFor } from "@ember/object/internals"; +import { WithBoundArgs } from "@glint/template"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import TypeToConfirmInput from "./type-to-confirm/input"; + +type TypeToConfirmInputBoundArgs = + | "value" + | "id" + | "inputValue" + | "onInput" + | "onKeydown"; + +interface TypeToConfirmInterface { + Input: WithBoundArgs; + hasConfirmed: boolean; +} + +interface TypeToConfirmSignature { + Args: { + /** + * The target value to confirm. + */ + value: string; + + /** + * The action to run when the input is focused, + * valid, and the Enter key is pressed. + */ + onEnter?: () => void; + }; + Blocks: { + default: [T: TypeToConfirmInterface]; + }; +} + +export default class TypeToConfirm extends Component { + /** + * The typed value of the input. Updated on `input` events and used + * to compare against the `value` argument to determine validity. + */ + @tracked protected inputValue = ""; + + /** + * Whether the input value matches the `value` argument. + * Used internally to determine if the `onEnter` action should be called. + * Yielded to the block to allow for custom UI based on the input's validity, + */ + @tracked protected hasConfirmed = false; + + /** + * The unique identifier for the component. + * Used to associate the input with its label. + */ + protected get id() { + return guidFor(this); + } + + /** + * The action to update and validate the input value. + * Called on `input` events. If the input value matches the `value` argument, + * sets `hasConfirmed` to `true`. Otherwise, sets `hasConfirmed` to `false`. + */ + @action protected onInput(event: Event) { + this.inputValue = (event.target as HTMLInputElement).value; + + if (this.inputValue === this.args.value) { + this.hasConfirmed = true; + } else { + this.hasConfirmed = false; + } + } + + /** + * The action to run on input keydown. + * Calls the passed in `onEnter` action if it's present, + * the input is valid, and the Enter key is pressed. + */ + @action protected onKeydown(event: KeyboardEvent) { + const { onEnter } = this.args; + + if (onEnter && this.hasConfirmed && event.key === "Enter") { + onEnter(); + } + } +} + +declare module "@glint/environment-ember-loose/registry" { + export default interface Registry { + TypeToConfirm: typeof TypeToConfirm; + } +} diff --git a/web/app/components/type-to-confirm/input.hbs b/web/app/components/type-to-confirm/input.hbs new file mode 100644 index 000000000..eed66b592 --- /dev/null +++ b/web/app/components/type-to-confirm/input.hbs @@ -0,0 +1,17 @@ + + + diff --git a/web/app/components/type-to-confirm/input.ts b/web/app/components/type-to-confirm/input.ts new file mode 100644 index 000000000..530c7dfbe --- /dev/null +++ b/web/app/components/type-to-confirm/input.ts @@ -0,0 +1,24 @@ +import Component from "@glimmer/component"; + +interface TypeToConfirmInputSignature { + Element: HTMLInputElement; + Args: { + onInput: (event: Event) => void; + onKeydown: (event: KeyboardEvent) => void; + inputValue: string; + value: string; + id: string; + }; + Blocks: { + default: []; + }; +} + +export default class TypeToConfirmInput extends Component {} + +declare module "@glint/environment-ember-loose/registry" { + export default interface Registry { + // Only validate invocation via the `component` helper + "type-to-confirm/input": typeof TypeToConfirmInput; + } +} diff --git a/web/app/styles/components/multiselect.scss b/web/app/styles/components/multiselect.scss index 6f7dc98ec..9b00a4c3c 100644 --- a/web/app/styles/components/multiselect.scss +++ b/web/app/styles/components/multiselect.scss @@ -14,6 +14,14 @@ min-width: 0; } + &.selection-made { + @apply bg-none; + + input { + @apply hidden; + } + } + &.ember-power-select-trigger, &.ember-power-select-trigger-multiple-input { line-height: 1; diff --git a/web/app/styles/typography.scss b/web/app/styles/typography.scss index 7b789b002..5bc4012cf 100644 --- a/web/app/styles/typography.scss +++ b/web/app/styles/typography.scss @@ -1,6 +1,11 @@ @use "variables" as v; -mark { +em { + @apply not-italic; +} + +mark, +.mark { @apply bg-color-surface-warning text-color-foreground-warning-on-surface; } diff --git a/web/mirage/factories/google/person.ts b/web/mirage/factories/google/person.ts index c874f4ad1..a87040417 100644 --- a/web/mirage/factories/google/person.ts +++ b/web/mirage/factories/google/person.ts @@ -1,5 +1,4 @@ import { Factory } from "miragejs"; -import { assert } from "@ember/debug"; import { TEST_USER_PHOTO } from "../../utils"; export default Factory.extend({ diff --git a/web/tests/acceptance/authenticated/document-test.ts b/web/tests/acceptance/authenticated/document-test.ts index 1d90ee75a..341a8ae5a 100644 --- a/web/tests/acceptance/authenticated/document-test.ts +++ b/web/tests/acceptance/authenticated/document-test.ts @@ -27,6 +27,7 @@ import { TEST_USER_EMAIL, TEST_SHORT_LINK_BASE_URL, TEST_USER_NAME, + TEST_USER_2_NAME, } from "hermes/mirage/utils"; import { Response } from "miragejs"; @@ -64,8 +65,20 @@ const POPOVER = "[data-test-x-dropdown-list-content]"; const PRODUCT_SELECT_DROPDOWN_ITEM = `${POPOVER} [data-test-product-select-item]`; const TOGGLE_SELECT = "[data-test-x-dropdown-list-toggle-select]"; -const DISABLED_FOOTER_H5 = "[data-test-disabled-footer-h5]"; +/** + * Transfer ownership + */ +const TRANSFER_OWNERSHIP_BUTTON = + "[data-test-transfer-document-ownership-button]"; +const TRANSFER_OWNERSHIP_MODAL = "[data-test-transfer-ownership-modal]"; +const OWNERSHIP_TRANSFERRED_MODAL = "[data-test-ownership-transferred-modal]"; +const TRANSFERRING_DOC = "[data-test-transferring-doc]"; +const SELECT_NEW_OWNER_LABEL = "[data-test-select-new-owner-label]"; +const PEOPLE_SELECT_INPUT = ".ember-power-select-trigger-multiple-input"; +const PEOPLE_SELECT_OPTION = + ".ember-power-select-option:not(.ember-power-select-option--no-matches-message)"; +const DISABLED_FOOTER_H5 = "[data-test-disabled-footer-h5]"; const OWNER_LINK = "[data-test-owner-link]"; const EDITABLE_FIELD_READ_VALUE = "[data-test-editable-field-read-value]"; @@ -79,7 +92,7 @@ const PUBLISH_FOR_REVIEW_MODAL_SELECTOR = "[data-test-publish-for-review-modal]"; const DELETE_BUTTON = "[data-test-delete-draft-button]"; const DELETE_MODAL = "[data-test-delete-draft-modal]"; -const DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR = +const DOCUMENT_MODAL_PRIMARY_BUTTON = "[data-test-document-modal-primary-button]"; const PUBLISHING_FOR_REVIEW_MESSAGE_SELECTOR = "[data-test-publishing-for-review-message]"; @@ -101,7 +114,7 @@ const EDITABLE_FIELD_SAVE_BUTTON_SELECTOR = ".editable-field [data-test-save-button]"; const PEOPLE_SELECT_REMOVE_BUTTON_SELECTOR = ".ember-power-select-multiple-remove-btn"; - +const TYPE_TO_CONFIRM_INPUT = "[data-test-type-to-confirm-input]"; const PROJECT_LINK = "[data-test-project-link]"; const ADD_TO_PROJECT_BUTTON = "[data-test-section-header-button-for='Projects']"; @@ -546,9 +559,9 @@ module("Acceptance | authenticated/document", function (hooks) { assert.dom(DELETE_MODAL).exists("the user is shown a confirmation screen"); - assert.dom(DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR).hasText("Yes, delete"); + assert.dom(DOCUMENT_MODAL_PRIMARY_BUTTON).hasText("Yes, delete"); - await click(DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR); + await click(DOCUMENT_MODAL_PRIMARY_BUTTON); assert.dom(DELETE_MODAL).doesNotExist("the modal is dismissed"); @@ -695,6 +708,19 @@ module("Acceptance | authenticated/document", function (hooks) { .hasText("In review", "the status is shown but not as a toggle"); }); + test("non-owners don't see the transfer-ownership button", async function (this: AuthenticatedDocumentRouteTestContext, assert) { + this.server.create("document", { + objectID: 1, + isDraft: false, + status: "In-Review", + owners: [TEST_USER_2_EMAIL], + }); + + await visit("/document/1"); + + assert.dom(TRANSFER_OWNERSHIP_BUTTON).doesNotExist(); + }); + test("doc owners can publish their docs for review", async function (this: AuthenticatedDocumentRouteTestContext, assert) { this.server.create("document", { objectID: 1, @@ -709,7 +735,7 @@ module("Acceptance | authenticated/document", function (hooks) { assert.dom(PUBLISH_FOR_REVIEW_MODAL_SELECTOR).exists(); - let clickPromise = click(DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR); + let clickPromise = click(DOCUMENT_MODAL_PRIMARY_BUTTON); await waitFor(PUBLISHING_FOR_REVIEW_MESSAGE_SELECTOR); assert.dom(PUBLISHING_FOR_REVIEW_MESSAGE_SELECTOR).exists(); @@ -749,7 +775,7 @@ module("Acceptance | authenticated/document", function (hooks) { await visit("/document/1?draft=true"); await click(SIDEBAR_PUBLISH_FOR_REVIEW_BUTTON_SELECTOR); - await click(DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR); + await click(DOCUMENT_MODAL_PRIMARY_BUTTON); await waitFor(DOC_PUBLISHED_MODAL_SELECTOR); assert.dom(DOC_PUBLISHED_MODAL_SELECTOR).exists(); @@ -1328,7 +1354,7 @@ module("Acceptance | authenticated/document", function (hooks) { await click(SIDEBAR_PUBLISH_FOR_REVIEW_BUTTON_SELECTOR); - await click(DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR); + await click(DOCUMENT_MODAL_PRIMARY_BUTTON); assert.dom(MODAL_ERROR).containsText(ERROR_MESSAGE_TEXT); }); @@ -1347,7 +1373,7 @@ module("Acceptance | authenticated/document", function (hooks) { await click(DELETE_BUTTON); - await click(DOCUMENT_MODAL_PRIMARY_BUTTON_SELECTOR); + await click(DOCUMENT_MODAL_PRIMARY_BUTTON); assert.dom(MODAL_ERROR).containsText(ERROR_MESSAGE_TEXT); }); @@ -1569,4 +1595,125 @@ module("Acceptance | authenticated/document", function (hooks) { assert.dom(DISABLED_FOOTER_H5).hasText("Document is locked"); }); + + test("owners can transfer ownership of their docs", async function (this: AuthenticatedDocumentRouteTestContext, assert) { + this.server.create("document", { + id: 1, + objectID: 1, + }); + + this.server.create("google/person", { + emailAddresses: [{ value: TEST_USER_EMAIL }], + }); + + this.server.create("google/person", { + emailAddresses: [{ value: TEST_USER_2_EMAIL }], + }); + + await visit("/document/1"); + + await click(TRANSFER_OWNERSHIP_BUTTON); + + assert.dom(TRANSFER_OWNERSHIP_MODAL).exists(); + + assert + .dom(DOCUMENT_MODAL_PRIMARY_BUTTON) + .hasText("Transfer doc") + .isDisabled(); + + await click(SELECT_NEW_OWNER_LABEL); + + assert + .dom(PEOPLE_SELECT_INPUT) + .isFocused("clicking the label focuses the input"); + + await fillIn(PEOPLE_SELECT_INPUT, TEST_USER_EMAIL); + + assert + .dom(PEOPLE_SELECT_OPTION) + .doesNotExist("the authenticated user is not shown in the people select"); + + await fillIn(PEOPLE_SELECT_INPUT, TEST_USER_2_EMAIL); + + assert.dom(PEOPLE_SELECT_OPTION).containsText(TEST_USER_2_EMAIL); + + await click(PEOPLE_SELECT_OPTION); + + assert + .dom(PEOPLE_SELECT_INPUT) + .isNotVisible("the input is hidden after selection"); + + assert + .dom(TYPE_TO_CONFIRM_INPUT) + .isFocused( + "the type-to-confirm input receives focus when a person is selected", + ); + + assert.dom(DOCUMENT_MODAL_PRIMARY_BUTTON).isDisabled(); + + await fillIn(TYPE_TO_CONFIRM_INPUT, "transfer"); + + assert + .dom(DOCUMENT_MODAL_PRIMARY_BUTTON) + .isNotDisabled("the button is enabled when both inputs are filled"); + + const clickPromise = click(DOCUMENT_MODAL_PRIMARY_BUTTON); + + await waitFor(TRANSFERRING_DOC); + + assert.dom(TRANSFERRING_DOC).containsText("Transferring doc..."); + + await clickPromise; + + assert.dom(TRANSFER_OWNERSHIP_MODAL).doesNotExist(); + + assert + .dom(OWNERSHIP_TRANSFERRED_MODAL) + .containsText("Ownership transferred") + .containsText("User 2 has been notified of the change."); + + assert.dom(DOCUMENT_MODAL_PRIMARY_BUTTON).hasText("Close"); + + await click(DOCUMENT_MODAL_PRIMARY_BUTTON); + + assert.dom(OWNERSHIP_TRANSFERRED_MODAL).doesNotExist(); + + const doc = this.server.schema.document.find(1); + const docOwner = doc.attrs.owners[0]; + + assert.equal( + docOwner, + TEST_USER_2_EMAIL, + "the doc owner is updated in the back end", + ); + }); + + test("an error is shown when ownership transferring fails", async function (this: AuthenticatedDocumentRouteTestContext, assert) { + this.server.create("document", { + id: 1, + objectID: 1, + isDraft: false, + status: "In-review", + }); + + this.server.create("google/person", { + emailAddresses: [{ value: TEST_USER_2_EMAIL }], + }); + + await visit("/document/1"); + + await click(TRANSFER_OWNERSHIP_BUTTON); + + await fillIn(PEOPLE_SELECT_INPUT, TEST_USER_2_EMAIL); + await click(PEOPLE_SELECT_OPTION); + await fillIn(TYPE_TO_CONFIRM_INPUT, "transfer"); + + this.server.patch("/documents/:document_id", () => { + return new Response(500, {}, "Error"); + }); + + await click(DOCUMENT_MODAL_PRIMARY_BUTTON); + + assert.dom(MODAL_ERROR).exists(); + }); }); diff --git a/web/tests/integration/components/document/modal-test.ts b/web/tests/integration/components/document/modal-test.ts index 2225ac248..e304b29e4 100644 --- a/web/tests/integration/components/document/modal-test.ts +++ b/web/tests/integration/components/document/modal-test.ts @@ -11,6 +11,10 @@ import { import { hbs } from "ember-cli-htmlbars"; import { assert as emberAssert } from "@ember/debug"; +const PRIMARY_BUTTON = "[data-test-document-modal-primary-button]"; +const SECONDARY_BUTTON = "[data-test-document-modal-secondary-button]"; +const ERROR = ".hds-alert"; + interface DocumentModalTestContext extends TestContext { color?: string; headerText: string; @@ -68,10 +72,10 @@ module("Integration | Component | document/modal", function (hooks) { .dom(".hds-modal__body") .hasText( "Are you sure you want to archive this document?", - "can take a @bodyText argument" + "can take a @bodyText argument", ); - const primaryButton = find("[data-test-document-modal-primary-button]"); + const primaryButton = find(PRIMARY_BUTTON); emberAssert("primary button must exist", primaryButton); @@ -86,10 +90,10 @@ module("Integration | Component | document/modal", function (hooks) { .hasAttribute( "data-test-icon", "archive", - "can take a @taskButtonIcon argument" + "can take a @taskButtonIcon argument", ); - assert.dom(".hds-alert").doesNotExist("error is not shown by default"); + assert.dom(ERROR).doesNotExist("error is not shown by default"); this.set("task", async () => { throw new Error("error"); @@ -97,12 +101,12 @@ module("Integration | Component | document/modal", function (hooks) { await click(primaryButton); - assert.dom(".hds-alert").exists("failed tasks show an error"); - assert.dom(".hds-alert .hds-alert__title").hasText("Error title"); + assert.dom(ERROR).exists("failed tasks show an error"); + assert.dom(`${ERROR} .hds-alert__title`).hasText("Error title"); await click(".hds-alert__dismiss"); - assert.dom(".hds-alert").doesNotExist("error can be dismissed"); + assert.dom(ERROR).doesNotExist("error can be dismissed"); }); test("it yields a body block with a taskIsRunning property", async function (assert) { @@ -134,7 +138,7 @@ module("Integration | Component | document/modal", function (hooks) { assert.dom("[data-test-body-block]").hasText("idle"); - const clickPromise = click("[data-test-document-modal-primary-button]"); + const clickPromise = click(PRIMARY_BUTTON); await waitFor("[data-test-body-block] span"); @@ -159,16 +163,15 @@ module("Integration | Component | document/modal", function (hooks) { /> `); - const buttonSelector = "[data-test-document-modal-primary-button]"; - const iconSelector = buttonSelector + " .flight-icon"; + const iconSelector = PRIMARY_BUTTON + " .flight-icon"; - assert.dom(buttonSelector).hasText("Yes, archive"); + assert.dom(PRIMARY_BUTTON).hasText("Yes, archive"); assert.dom(iconSelector).hasAttribute("data-test-icon", "archive"); - const clickPromise = click(buttonSelector); + const clickPromise = click(PRIMARY_BUTTON); await waitUntil(() => { - return find(buttonSelector)?.textContent?.trim() === "Archiving..."; + return find(PRIMARY_BUTTON)?.textContent?.trim() === "Archiving..."; }); assert.dom(iconSelector).hasAttribute("data-test-icon", "loading"); @@ -192,9 +195,7 @@ module("Integration | Component | document/modal", function (hooks) { /> `); - assert - .dom("[data-test-document-modal-primary-button]") - .hasAttribute("disabled"); + assert.dom(PRIMARY_BUTTON).hasAttribute("disabled"); }); test("the close action runs when the modal is dismissed", async function (assert) { @@ -215,7 +216,7 @@ module("Integration | Component | document/modal", function (hooks) { /> `); - await click("[data-test-document-modal-secondary-button]"); + await click(SECONDARY_BUTTON); await waitUntil(() => count === 1); assert.equal(count, 1); @@ -236,7 +237,7 @@ module("Integration | Component | document/modal", function (hooks) { assert.dom("[data-test-document-modal-footer]").exists(); - const clickPromise = click("[data-test-document-modal-primary-button]"); + const clickPromise = click(PRIMARY_BUTTON); await waitUntil(() => { return find("[data-test-document-modal-footer]") === null; @@ -246,4 +247,45 @@ module("Integration | Component | document/modal", function (hooks) { await clickPromise; }); + + test("the secondary button can be hidden", async function (assert) { + await render(hbs` + + `); + + assert.dom(SECONDARY_BUTTON).doesNotExist(); + }); + + test("errors are not shown when a full-modal task is running", async function (assert) { + this.set("task", async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + throw new Error("error"); + }); + + await render(hbs` + + `); + + await click(PRIMARY_BUTTON); + + assert.dom(ERROR).exists(); + + const clickPromise = click(PRIMARY_BUTTON); + + await waitUntil(() => { + return find(ERROR) === null; + }); + + await clickPromise; + }); }); diff --git a/web/tests/integration/components/inputs/people-select-test.ts b/web/tests/integration/components/inputs/people-select-test.ts index 665331116..a15a4b1df 100644 --- a/web/tests/integration/components/inputs/people-select-test.ts +++ b/web/tests/integration/components/inputs/people-select-test.ts @@ -1,16 +1,25 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "ember-qunit"; import { hbs } from "ember-cli-htmlbars"; -import { click, fillIn, render, waitFor } from "@ember/test-helpers"; +import { click, fillIn, render, rerender, waitFor } from "@ember/test-helpers"; import { setupMirage } from "ember-cli-mirage/test-support"; import { MirageTestContext } from "ember-cli-mirage/test-support"; -import { authenticateTestUser } from "hermes/mirage/utils"; +import { TEST_USER_EMAIL, authenticateTestUser } from "hermes/mirage/utils"; import { Response } from "miragejs"; +const MULTISELECT = ".multiselect"; +const TRIGGER = ".ember-basic-dropdown-trigger"; +const INPUT = ".ember-power-select-trigger-multiple-input"; +const OPTION = + ".ember-power-select-option:not(.ember-power-select-option--no-matches-message)"; +const NO_MATCHES_MESSAGE = ".ember-power-select-option--no-matches-message"; + interface PeopleSelectContext extends MirageTestContext { people: string[]; onChange: (newValue: string[]) => void; isFirstFetchAttempt: boolean; + excludeSelf: boolean; + isSingleSelect: boolean; } module("Integration | Component | inputs/people-select", function (hooks) { @@ -19,13 +28,13 @@ module("Integration | Component | inputs/people-select", function (hooks) { hooks.beforeEach(function (this: PeopleSelectContext) { authenticateTestUser(this); - }); - - test("it functions as expected", async function (this: PeopleSelectContext, assert) { this.server.createList("google/person", 10); + this.set("people", []); - this.onChange = (newValue) => this.set("people", newValue); + this.set("onChange", (newValue: string[]) => this.set("people", newValue)); + }); + test("it functions as expected", async function (this: PeopleSelectContext, assert) { await render(hbs` `); - await click(".ember-basic-dropdown-trigger"); + await click(TRIGGER); - assert - .dom(".ember-power-select-option") - .doesNotExist('"Type to search" message is hidden'); + assert.dom(OPTION).doesNotExist('"Type to search" message is hidden'); - await fillIn(".ember-power-select-trigger-multiple-input", "u"); + await fillIn(INPUT, "u"); assert - .dom(".ember-power-select-option") + .dom(OPTION) .exists({ count: 10 }, "Options matching `u` are suggested"); - await fillIn(".ember-power-select-trigger-multiple-input", "1"); + await fillIn(INPUT, "1"); - assert - .dom(".ember-power-select-option") - .exists({ count: 2 }, "Results are filtered to match 1"); + assert.dom(OPTION).exists({ count: 2 }, "Results are filtered to match 1"); - await click(".ember-power-select-option"); + await click(OPTION); assert .dom(".ember-power-select-multiple-option .person-email") .hasText("User 1", "User 1 was successfully selected"); - await fillIn(".ember-power-select-trigger-multiple-input", "2"); + await fillIn(INPUT, "2"); - await click(".ember-power-select-option"); + await click(OPTION); assert .dom(".ember-power-select-multiple-option .person-email") .exists({ count: 2 }, "User 2 was successfully selected"); - await fillIn(".ember-power-select-trigger-multiple-input", "2"); + await fillIn(INPUT, "2"); assert - .dom(".ember-power-select-option") + .dom(NO_MATCHES_MESSAGE) .hasText("No results found", "No duplicate users can be added"); await click( @@ -78,10 +83,6 @@ module("Integration | Component | inputs/people-select", function (hooks) { }); test("it will retry if the server returns an error", async function (this: PeopleSelectContext, assert) { - this.server.createList("google/person", 5); - - this.set("people", []); - this.onChange = (newValue) => this.set("people", newValue); this.set("isFirstFetchAttempt", true); this.server.post("/people", () => { @@ -103,12 +104,9 @@ module("Integration | Component | inputs/people-select", function (hooks) { /> `); - await click(".ember-basic-dropdown-trigger"); + await click(TRIGGER); - let fillInPromise = fillIn( - ".ember-power-select-trigger-multiple-input", - "any text - we're not actually querying", - ); + let fillInPromise = fillIn(INPUT, "any text - we're not actually querying"); await waitFor(".ember-power-select-option--loading-message"); @@ -118,8 +116,59 @@ module("Integration | Component | inputs/people-select", function (hooks) { await fillInPromise; + assert.dom(OPTION).exists({ count: 10 }, "Returns results after retrying"); + }); + + test("you can exclude the authenticated user from the list", async function (this: PeopleSelectContext, assert) { + this.server.create("google/person", { + emailAddresses: [{ value: TEST_USER_EMAIL }], + }); + + this.set("excludeSelf", true); + + await render(hbs` + + `); + + await click(TRIGGER); + await fillIn(INPUT, TEST_USER_EMAIL); + + assert + .dom(OPTION) + .doesNotExist("Authenticated user is not in the list of options"); + + this.set("excludeSelf", false); + + await click(TRIGGER); + await fillIn(INPUT, TEST_USER_EMAIL); + assert - .dom(".ember-power-select-option") - .exists({ count: 5 }, "Returns results after retrying"); + .dom(OPTION) + .exists({ count: 1 }, "Authenticated user is in the list of options"); + }); + + test("you can limit the selection to a single person", async function (this: PeopleSelectContext, assert) { + this.set("isSingleSelect", true); + + await render(hbs` + + `); + + assert.dom(MULTISELECT).doesNotHaveClass("selection-made"); + + await click(TRIGGER); + await fillIn(INPUT, "u"); + await click(OPTION); + + assert.dom(MULTISELECT).hasClass("selection-made"); + assert.dom(INPUT).isNotVisible("input is hidden after a selection is made"); }); }); diff --git a/web/tests/integration/components/type-to-confirm-test.ts b/web/tests/integration/components/type-to-confirm-test.ts new file mode 100644 index 000000000..5db19aedd --- /dev/null +++ b/web/tests/integration/components/type-to-confirm-test.ts @@ -0,0 +1,97 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "ember-qunit"; +import { + TestContext, + fillIn, + render, + triggerKeyEvent, +} from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import htmlElement from "hermes/utils/html-element"; + +const VALUE = "fried food"; +const LABEL = "[data-test-type-to-confirm-label]"; +const INPUT = "[data-test-type-to-confirm-input]"; + +interface Context extends TestContext { + value: string; + onEnter: () => void; +} + +module("Integration | Component | type-to-confirm", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function (this: Context) { + this.value = VALUE; + }); + + test("it yields an input component", async function (this: Context, assert) { + await render(hbs` + + + + `); + + assert.dom(LABEL).hasText(`Type ${VALUE} to confirm`); + assert.dom(INPUT).exists(); + }); + + test("it yields a `hasConfirmed` value", async function (this: Context, assert) { + await render(hbs` + +
+ {{#if T.hasConfirmed}} + true + {{else}} + false + {{/if}} +
+ +
+ `); + + const selector = "[data-test-has-confirmed]"; + + assert.dom(selector).hasText("false"); + + await fillIn(INPUT, VALUE); + + assert.dom(selector).hasText("true"); + }); + + test("it generates an id", async function (this: Context, assert) { + await render(hbs` + + + + `); + + const labelFor = htmlElement(LABEL).getAttribute("for"); + const inputId = htmlElement(INPUT).getAttribute("id"); + + assert.equal(labelFor, inputId); + }); + + test("in the `hasConfirmed` state, keying Enter runs the passed-in `onEnter` action ", async function (this: Context, assert) { + let count = 0; + + this.set("onEnter", () => { + count++; + }); + + await render(hbs` + + + + `); + + await triggerKeyEvent(INPUT, "keydown", "Enter"); + + assert.equal(count, 0); + + await fillIn(INPUT, VALUE); + await triggerKeyEvent(INPUT, "keydown", "Enter"); + + assert.equal(count, 1); + }); +});