diff --git a/web/app/components/custom-editable-field.hbs b/web/app/components/custom-editable-field.hbs index fd89413c3..be7c5f777 100644 --- a/web/app/components/custom-editable-field.hbs +++ b/web/app/components/custom-editable-field.hbs @@ -6,27 +6,21 @@ @loading={{@loading}} @disabled={{@disabled}} > - <:default> - {{#let (get @document @field) as |documentField|}} -

- {{#if documentField}} - {{documentField}} - {{else}} - - {{/if}} + <:default as |F|> + {{#if F.value}} +

+ {{F.value}}

- {{/let}} + {{else}} + + {{/if}} <:editing as |F|> @@ -48,7 +42,7 @@ {{#if person.imgURL}} {{/if}} {{person.email}} diff --git a/web/app/components/custom-editable-fields/empty-state.ts b/web/app/components/custom-editable-fields/empty-state.ts new file mode 100644 index 000000000..ad8276300 --- /dev/null +++ b/web/app/components/custom-editable-fields/empty-state.ts @@ -0,0 +1,18 @@ +import Component from '@glimmer/component'; + +interface CustomEditableFieldsEmptyStateComponentSignature { + Element: null; + Args: { + }; + Blocks: { + }; +} + +export default class CustomEditableFieldsEmptyStateComponent extends Component { +} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'CustomEditableFields::EmptyState': typeof CustomEditableFieldsEmptyStateComponent; + } +} diff --git a/web/app/components/document/sidebar.hbs b/web/app/components/document/sidebar.hbs index 4c216096e..4826d42f4 100644 --- a/web/app/components/document/sidebar.hbs +++ b/web/app/components/document/sidebar.hbs @@ -98,94 +98,75 @@ {{/if}} - {{#if this.isOwner}} -
- - <:default> - {{#unless (is-empty this.title)}} -

{{this.title}}

- {{else}} -

- Enter a title here. -

- {{/unless}} - - <:editing as |F|> - - -
-
- {{else}} -

{{this.title}}

- {{/if}} - - -
- - {{! Summary }} -
- - {{#if this.isOwner}} +
- <:default> - {{#unless (is-empty this.summary)}} -

- {{this.summary}}

- {{else}} -

- Enter a summary here.

- {{/unless}} + <:default as |F|> +

+ {{F.value}} +

<:editing as |F|> + name="title" + as |FF| + > + {{#if F.emptyValueErrorIsShown}} + + Title is required. + + {{/if}} +
- {{else}} -

- {{this.summary}} -

- {{/if}} +
+
+ +
+ + {{! Summary }} +
+ + + <:default as |F|> +

+ {{or F.value "Enter a summary"}} +

+ + <:editing as |F|> + + +
@@ -227,41 +208,15 @@
- {{#if this.isOwner}} - - <:default> - {{#if this.contributors.length}} -
    - {{#each this.contributors as |contributor|}} -
  1. - -
  2. - {{/each}} -
- {{else}} - No contributors - {{/if}} - - <:editing as |F|> - - -
- {{else}} -
+ + <:default> {{#if this.contributors.length}}
    {{#each this.contributors as |contributor|}} @@ -276,48 +231,30 @@ {{else}} No contributors {{/if}} -
- {{/if}} + + <:editing as |F|> + + +
- {{#if this.isOwner}} - - <:default> - {{#if this.approvers.length}} -
    - {{#each this.approvers as |approver|}} -
  1. - -
  2. - {{/each}} -
- {{else}} - No approvers - {{/if}} - - <:editing as |F|> - - -
- {{else}} -
+ + <:default> {{#if this.approvers.length}}
    {{#each this.approvers as |approver|}} @@ -333,8 +270,17 @@ {{else}} No approvers {{/if}} -
- {{/if}} + + <:editing as |F|> + + +
@@ -374,7 +320,7 @@ @attributes={{attributes}} @onChange={{perform this.save field}} @loading={{this.save.isRunning}} - @disabled={{this.editingIsDisabled}} + @disabled={{not this.isOwner}} />
{{/if}} diff --git a/web/app/components/document/sidebar.ts b/web/app/components/document/sidebar.ts index eb0e542b3..05a6d1fa3 100644 --- a/web/app/components/document/sidebar.ts +++ b/web/app/components/document/sidebar.ts @@ -584,7 +584,7 @@ export default class DocumentSidebarComponent extends Component { - if (field && val) { + if (field && val !== undefined) { let serializedValue; if (typeof val === "string") { @@ -598,9 +598,6 @@ export default class DocumentSidebarComponent extends Component { + const cachedValue = this.summary; + this.summary = summary; + try { + this.save.perform("summary", this.summary); + } catch { + this.summary = cachedValue; + } + }); + @action closeDeleteModal() { this.deleteModalIsShown = false; } diff --git a/web/app/components/editable-field.hbs b/web/app/components/editable-field.hbs index 3d7c871de..63d26b61f 100644 --- a/web/app/components/editable-field.hbs +++ b/web/app/components/editable-field.hbs @@ -1,18 +1,25 @@ -
- {{#if (and this.editing (not @loading))}} -
- {{yield (hash value=@value update=this.update) to="editing"}} -
+
+ {{#if (and this.editingIsEnabled (not @loading))}} + {{on-document "keydown" this.handleKeydown}} + {{yield + (hash + value=this.value + update=this.maybeUpdateValue + input=this.inputModifier + emptyValueErrorIsShown=this.emptyValueErrorIsShown + ) + to="editing" + }} {{else}} {{#if @loading}} {{/if}} - + {{yield (hash value=this.value)}} + {{/if}}
diff --git a/web/app/components/editable-field.ts b/web/app/components/editable-field.ts index 00e627700..6d3affcd2 100644 --- a/web/app/components/editable-field.ts +++ b/web/app/components/editable-field.ts @@ -1,8 +1,10 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; -import { scheduleOnce } from "@ember/runloop"; +import { schedule, scheduleOnce } from "@ember/runloop"; import { assert } from "@ember/debug"; +import { modifier } from "ember-modifier"; +import { ModifierLike } from "@glint/template"; export const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; @@ -14,74 +16,172 @@ interface EditableFieldComponentSignature { onChange: (value: any) => void; loading?: boolean; disabled?: boolean; + isRequired?: boolean; }; Blocks: { - default: []; + default: [value: any]; editing: [ F: { value: any; update: (value: any) => void; + input: ModifierLike<{ + Element: HTMLInputElement | HTMLTextAreaElement; + Return: void; + }>; + emptyValueErrorIsShown: boolean; } ]; }; } export default class EditableFieldComponent extends Component { - @tracked protected editing = false; - @tracked protected el: HTMLElement | null = null; - @tracked protected cachedValue = null; + /** + * The cached value of the field. + * Initially set to the value passed in; updated when committed. + * Required to handle both the PEOPLE and STRING fieldTypes, + * which are processed differently by their sub-components. + */ + private cachedValue = this.args.value; - @action protected captureElement(el: HTMLElement) { - this.el = el; - } + /** + * The value of the field. Initially set to the value passed in. + * Updated when the user commits their changes. + */ + @tracked protected value = this.args.value; - @action protected edit() { - this.cachedValue = this.args.value; - this.editing = true; + /** + * Whether the <:editing> block is enabled. + * Set true by clicking the <:default> content. + * Set false by the `disableEditing` action on blur. + */ + @tracked protected editingIsEnabled = false; - // Kinda gross, but this gives focus to the first focusable element in the - // :editing block, which will typically be an input. - scheduleOnce("afterRender", this, () => { - if (this.el && !this.el.contains(document.activeElement)) { - const firstInput = this.el.querySelector(FOCUSABLE); - if (firstInput) (firstInput as HTMLElement).focus(); - } - }); - } + /** + * Whether the empty-value error is shown. + * Set true when a required field is committed with an empty value. + * Yielded to the <:editing> block to show/hide the error. + */ + @tracked protected emptyValueErrorIsShown = false; + + /** + * Whether the user has cancelled their edit using the Escape key. + * Used on blur to determine whether to reset the value to the original. + * Set true by the `handleKeydown` task on Escape keydown. + */ + @tracked private hasCancelled = false; - @action protected cancel(ev: KeyboardEvent) { - if (ev.key === "Escape") { - ev.preventDefault(); - scheduleOnce("actions", this, () => { - this.editing = false; + /** + * The input or textarea element, if using. + * Registered by the `inputModifier` action, used for focusing and blurring. + */ + @tracked private inputElement: HTMLInputElement | HTMLTextAreaElement | null = + null; + + /** + * The modifier passed to the `editing` block to apply to the input or textarea. + * Autofocuses the input and adds a blur listener to commit changes. + */ + protected inputModifier = modifier((element: HTMLElement) => { + this.inputElement = element as HTMLInputElement | HTMLTextAreaElement; + this.inputElement.focus(); + element.addEventListener("blur", this.onBlur); + return () => element.removeEventListener("blur", this.onBlur); + }); + + /** + * The action run when an `inputModifier`-registered input blurs. + * If blurring is the result of a cancel, the value is reset to the original, + * otherwise the value is passed to the `maybeUpdateValue` method. + */ + @action private onBlur(event: FocusEvent) { + if (this.hasCancelled) { + this.value = this.args.value; + schedule("actions", () => { + this.hasCancelled = false; }); + return; } + + this.maybeUpdateValue(event); } - @action protected preventNewlines(ev: KeyboardEvent) { - if (ev.key === "Enter") { - ev.preventDefault(); - } + /** + * The action to enable the <:editing> block. + * Called when a user clicks the <:default> content. + */ + @action protected enableEditing() { + this.editingIsEnabled = true; } - @action protected update(eventOrValue: Event | any) { - scheduleOnce("actions", this, () => { - this.editing = false; - }); + /** + * The action to disable the <:editing> block. + * Called when a user commits or cancels their edit. + */ + @action protected disableEditing() { + this.editingIsEnabled = false; + } + + /** + * The action to handle Enter and Escape keydowns while the <:editing> block is open. + * On Enter, the input blurs, causing `maybeUpdateValue` action to run. + * On Escape, we disable editing. + */ + @action protected handleKeydown(ev: KeyboardEvent) { + switch (ev.key) { + case "Enter": + ev.preventDefault(); + if (this.inputElement) { + this.inputElement.blur(); + } + break; + case "Escape": + ev.preventDefault(); + this.hasCancelled = true; + this.disableEditing(); + break; + } + } - let newValue = eventOrValue; + /** + * The action run when the <:editing> block is committed. + * Checks the value to see if it has changed, and if so, updates the value + * and resets the cached value. If the value is empty and the field is required, + * triggers the empty-value error. + */ + @action protected maybeUpdateValue(eventOrValue: Event | any) { + let newValue: string | string[] | undefined; if (eventOrValue instanceof Event) { const target = eventOrValue.target; assert("target must exist", target); assert("value must exist in the target", "value" in target); const value = target.value; - newValue = value; + newValue = value as string | string[]; + } else { + newValue = eventOrValue; } - if (newValue !== this.cachedValue) { - this.args.onChange?.(newValue); + // Stringified values work for both arrays and strings. + if (JSON.stringify(newValue) !== JSON.stringify(this.cachedValue)) { + if (newValue === "") { + if (this.args.isRequired) { + this.emptyValueErrorIsShown = true; + return; + } + // Nothing has really changed, so we don't update the value. + if (this.args.value === undefined) { + this.disableEditing(); + return; + } + } + + this.cachedValue = this.value = newValue; + this.args.onChange?.(this.value); } + + scheduleOnce("actions", this, () => { + this.disableEditing(); + }); } } diff --git a/web/app/components/inputs/people-select.ts b/web/app/components/inputs/people-select.ts index b68a06140..74eee7c2a 100644 --- a/web/app/components/inputs/people-select.ts +++ b/web/app/components/inputs/people-select.ts @@ -16,7 +16,6 @@ interface InputsPeopleSelectComponentSignature { Element: HTMLDivElement; Args: { selected: HermesUser[]; - onBlur?: () => void; onChange: (people: HermesUser[]) => void; }; } @@ -51,9 +50,6 @@ export default class InputsPeopleSelectComponent extends Component .field-toggle { all: unset; diff --git a/web/tests/acceptance/authenticated/document-test.ts b/web/tests/acceptance/authenticated/document-test.ts index bc9378bc9..826a06774 100644 --- a/web/tests/acceptance/authenticated/document-test.ts +++ b/web/tests/acceptance/authenticated/document-test.ts @@ -1,11 +1,11 @@ import { click, + fillIn, find, findAll, triggerEvent, visit, waitFor, - waitUntil, } from "@ember/test-helpers"; import { setupApplicationTest } from "ember-qunit"; import { module, test } from "qunit"; @@ -33,21 +33,15 @@ const DRAFT_VISIBILITY_TOGGLE_SELECTOR = "[data-test-draft-visibility-toggle]"; const COPY_URL_BUTTON_SELECTOR = "[data-test-sidebar-copy-url-button]"; const DRAFT_VISIBILITY_OPTION_SELECTOR = "[data-test-draft-visibility-option]"; const SECOND_DRAFT_VISIBILITY_LIST_ITEM_SELECTOR = `${DRAFT_VISIBILITY_DROPDOWN_SELECTOR} li:nth-child(2)`; -const EDITABLE_TITLE_SELECTOR = "[data-test-document-title-editable]"; -const EDITABLE_SUMMARY_SELECTOR = "[data-test-document-summary-editable]"; +const TITLE_SELECTOR = "[data-test-document-title]"; +const SUMMARY_SELECTOR = "[data-test-document-summary]"; +const CONTRIBUTORS_SELECTOR = "[data-test-document-contributors]"; +const APPROVERS_SELECTOR = "[data-test-document-approvers]"; + const EDITABLE_PRODUCT_AREA_SELECTOR = "[data-test-document-product-area-editable]"; -const EDITABLE_CONTRIBUTORS_SELECTOR = - "[data-test-document-contributors-editable]"; -const EDITABLE_APPROVERS_SELECTOR = "[data-test-document-approvers-editable]"; - -const READ_ONLY_TITLE_SELECTOR = "[data-test-document-title-read-only]"; -const READ_ONLY_SUMMARY_SELECTOR = "[data-test-document-summary-read-only]"; const READ_ONLY_PRODUCT_AREA_SELECTOR = "[data-test-document-product-area-read-only]"; -const READ_ONLY_CONTRIBUTORS_SELECTOR = - "[data-test-document-contributors-read-only]"; -const READ_ONLY_APPROVERS_SELECTOR = "[data-test-document-approvers-read-only]"; const SIDEBAR_PUBLISH_FOR_REVIEW_BUTTON_SELECTOR = "[data-test-sidebar-publish-for-review-button"; const PUBLISH_FOR_REVIEW_MODAL_SELECTOR = @@ -65,20 +59,16 @@ const DOC_PUBLISHED_COPY_URL_BUTTON_SELECTOR = "[data-test-doc-published-copy-url-button]"; const assertEditingIsDisabled = (assert: Assert) => { - assert.dom(EDITABLE_TITLE_SELECTOR).doesNotExist(); - assert.dom(EDITABLE_SUMMARY_SELECTOR).doesNotExist(); - assert.dom(EDITABLE_PRODUCT_AREA_SELECTOR).doesNotExist(); - assert.dom(EDITABLE_CONTRIBUTORS_SELECTOR).doesNotExist(); - assert.dom(EDITABLE_APPROVERS_SELECTOR).doesNotExist(); + assert.dom(TITLE_SELECTOR).doesNotHaveAttribute("data-test-editable"); + assert.dom(SUMMARY_SELECTOR).doesNotHaveAttribute("data-test-editable"); + assert.dom(CONTRIBUTORS_SELECTOR).doesNotHaveAttribute("data-test-editable"); + assert.dom(APPROVERS_SELECTOR).doesNotHaveAttribute("data-test-editable"); + assert.dom(EDITABLE_PRODUCT_AREA_SELECTOR).doesNotExist(); assert.dom(DRAFT_VISIBILITY_TOGGLE_SELECTOR).doesNotExist(); assert.dom(ADD_RELATED_RESOURCE_BUTTON_SELECTOR).doesNotExist(); - assert.dom(READ_ONLY_TITLE_SELECTOR).exists(); - assert.dom(READ_ONLY_SUMMARY_SELECTOR).exists(); assert.dom(READ_ONLY_PRODUCT_AREA_SELECTOR).exists(); - assert.dom(READ_ONLY_CONTRIBUTORS_SELECTOR).exists(); - assert.dom(READ_ONLY_APPROVERS_SELECTOR).exists(); }; interface AuthenticatedDocumentRouteTestContext extends MirageTestContext {} @@ -363,20 +353,16 @@ module("Acceptance | authenticated/document", function (hooks) { await visit("/document/1?draft=true"); - assert.dom(EDITABLE_TITLE_SELECTOR).exists(); - assert.dom(EDITABLE_SUMMARY_SELECTOR).exists(); - assert.dom(EDITABLE_PRODUCT_AREA_SELECTOR).exists(); - assert.dom(EDITABLE_CONTRIBUTORS_SELECTOR).exists(); - assert.dom(EDITABLE_APPROVERS_SELECTOR).exists(); + assert.dom(TITLE_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(SUMMARY_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(CONTRIBUTORS_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(APPROVERS_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(EDITABLE_PRODUCT_AREA_SELECTOR).exists(); assert.dom(DRAFT_VISIBILITY_TOGGLE_SELECTOR).exists(); assert.dom(ADD_RELATED_RESOURCE_BUTTON_SELECTOR).exists(); - assert.dom(READ_ONLY_TITLE_SELECTOR).doesNotExist(); - assert.dom(READ_ONLY_SUMMARY_SELECTOR).doesNotExist(); assert.dom(READ_ONLY_PRODUCT_AREA_SELECTOR).doesNotExist(); - assert.dom(READ_ONLY_CONTRIBUTORS_SELECTOR).doesNotExist(); - assert.dom(READ_ONLY_APPROVERS_SELECTOR).doesNotExist(); }); test("owners can edit everything but the product area of a published doc", async function (this: AuthenticatedDocumentRouteTestContext, assert) { @@ -388,20 +374,17 @@ module("Acceptance | authenticated/document", function (hooks) { await visit("/document/1"); - assert.dom(EDITABLE_TITLE_SELECTOR).exists(); - assert.dom(EDITABLE_SUMMARY_SELECTOR).exists(); - assert.dom(EDITABLE_PRODUCT_AREA_SELECTOR).doesNotExist(); - assert.dom(EDITABLE_CONTRIBUTORS_SELECTOR).exists(); - assert.dom(EDITABLE_APPROVERS_SELECTOR).exists(); + assert.dom(TITLE_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(SUMMARY_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(CONTRIBUTORS_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(APPROVERS_SELECTOR).hasAttribute("data-test-editable"); + assert.dom(EDITABLE_PRODUCT_AREA_SELECTOR).doesNotExist(); assert.dom(DRAFT_VISIBILITY_TOGGLE_SELECTOR).doesNotExist(); + assert.dom(ADD_RELATED_RESOURCE_BUTTON_SELECTOR).exists(); - assert.dom(READ_ONLY_TITLE_SELECTOR).doesNotExist(); - assert.dom(READ_ONLY_SUMMARY_SELECTOR).doesNotExist(); assert.dom(READ_ONLY_PRODUCT_AREA_SELECTOR).exists(); - assert.dom(READ_ONLY_CONTRIBUTORS_SELECTOR).doesNotExist(); - assert.dom(READ_ONLY_APPROVERS_SELECTOR).doesNotExist(); }); test("collaborators cannot edit the metadata of a draft", async function (this: AuthenticatedDocumentRouteTestContext, assert) { @@ -524,4 +507,21 @@ module("Acceptance | authenticated/document", function (hooks) { "the Continue button becomes the primary button when the copy link is hidden" ); }); + + test("non-required values can be reset by saving an empty value", async function (this: AuthenticatedDocumentRouteTestContext, assert) { + this.server.create("document", { + objectID: 1, + summary: "foo bar baz", + }); + + await visit("/document/1?draft=true"); + + await click(`${SUMMARY_SELECTOR} button`); + + await fillIn(`${SUMMARY_SELECTOR} textarea`, ""); + + await triggerEvent(`${SUMMARY_SELECTOR} textarea`, "blur"); + + assert.dom(SUMMARY_SELECTOR).hasText("Enter a summary"); + }); }); diff --git a/web/tests/integration/components/editable-field-test.ts b/web/tests/integration/components/editable-field-test.ts new file mode 100644 index 000000000..3c3f2f16f --- /dev/null +++ b/web/tests/integration/components/editable-field-test.ts @@ -0,0 +1,360 @@ +import { + click, + fillIn, + render, + triggerEvent, + triggerKeyEvent, + waitUntil, +} from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { MirageTestContext } from "ember-cli-mirage/test-support"; +import { setupRenderingTest } from "ember-qunit"; +import { module, test } from "qunit"; + +const EDITABLE_FIELD_SELECTOR = ".editable-field"; +const FIELD_TOGGLE_SELECTOR = ".editable-field .field-toggle"; +const LOADING_SPINNER_SELECTOR = ".loading-indicator"; + +interface EditableFieldComponentTestContext extends MirageTestContext { + onChange: (value: any) => void; + isLoading: boolean; + value: string; + newArray: string[]; +} + +module("Integration | Component | editable-field", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function (this: EditableFieldComponentTestContext) { + this.set("onChange", () => {}); + }); + + test("it handles splattributes", async function (this: EditableFieldComponentTestContext, assert) { + await render(hbs` + + `); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasClass("bar"); + }); + + test("it toggles between default and editing modes", async function (this: EditableFieldComponentTestContext, assert) { + await render(hbs` + + <:default>Foo + <:editing>Bar + + `); + + assert.dom(EDITABLE_FIELD_SELECTOR).exists({ count: 1 }).hasText("Foo"); + + await click(FIELD_TOGGLE_SELECTOR); + + assert.dom(EDITABLE_FIELD_SELECTOR).exists({ count: 1 }).hasText("Bar"); + }); + + test("it yields the expected value", async function (this: EditableFieldComponentTestContext, assert) { + await render(hbs` + + <:default as |F|>{{F.value}} one + <:editing as |F|>{{F.value}} two + + `); + + assert.dom(EDITABLE_FIELD_SELECTOR).exists({ count: 1 }).hasText("foo one"); + + await click(FIELD_TOGGLE_SELECTOR); + + assert.dom(EDITABLE_FIELD_SELECTOR).exists({ count: 1 }).hasText("foo two"); + }); + + test("it can show a loading state", async function (this: EditableFieldComponentTestContext, assert) { + this.set("isLoading", true); + + await render(hbs` + + `); + + assert.dom(FIELD_TOGGLE_SELECTOR).hasClass("loading").isDisabled(); + + assert.dom(LOADING_SPINNER_SELECTOR).exists(); + + this.set("isLoading", false); + + assert + .dom(FIELD_TOGGLE_SELECTOR) + .doesNotHaveClass("loading") + .isNotDisabled(); + + assert.dom(LOADING_SPINNER_SELECTOR).doesNotExist(); + }); + + test("it yields an emptyValueErrorIsShown property to the editing block", async function (this: EditableFieldComponentTestContext, assert) { + await render(hbs` + + <:default>Foo + <:editing as |F|> + + + {{#if F.emptyValueErrorIsShown}} +
Empty value error
+ {{/if}} + +
+ `); + + assert.dom(".error").doesNotExist(); + + await click(FIELD_TOGGLE_SELECTOR); + + assert.dom(".error").doesNotExist(); + + await fillIn("input", ""); + await triggerEvent("input", "blur"); + + assert.dom(".error").exists(); + }); + + test("the edit button can be disabled", async function (this: EditableFieldComponentTestContext, assert) { + await render(hbs` + + <:default>Foo + <:editing>Bar + + `); + + assert.dom(FIELD_TOGGLE_SELECTOR).isDisabled(); + }); + + test("it cancels when the escape key is pressed", async function (this: EditableFieldComponentTestContext, assert) { + const defaultText = "foo"; + + this.set("value", defaultText); + + await render(hbs` + + <:default as |F|>{{F.value}} + <:editing as |F|> + + + + `); + + await click(FIELD_TOGGLE_SELECTOR); + + await fillIn("input", "Baz"); + + await triggerEvent("input", "keydown", { + key: "Escape", + }); + + assert + .dom(EDITABLE_FIELD_SELECTOR) + .hasText(defaultText, "value reverts when escape is pressed"); + }); + + test("it runs the passed-in onChange action on blur", async function (this: EditableFieldComponentTestContext, assert) { + this.set("value", "foo"); + + this.set("onChange", (e: unknown) => { + this.set("value", e); + }); + + await render(hbs` + + <:default as |F|>{{F.value}} + <:editing as |F|> + + + + `); + + await click(FIELD_TOGGLE_SELECTOR); + + await fillIn("input", "bar"); + + // Keying "Enter" tests both `onBlur` and `handleKeydown` + // since `handleKeydown` ultimately calls `onBlur`. + await triggerKeyEvent("input", "keydown", "Enter"); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasText("bar"); + }); + + test("it yields an `update` (string) function to the editing block", async function (this: EditableFieldComponentTestContext, assert) { + this.set("value", "foo"); + this.set("onChange", (newValue: string) => { + this.set("value", newValue); + }); + + await render(hbs` + + <:default as |F|> + {{F.value}} + + <:editing as |F|> + + F.update + + + + `); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasText("foo"); + await click(FIELD_TOGGLE_SELECTOR); + + await click("button"); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasText("bar"); + }); + + test("it yields an `update` (array) function to the editing block", async function (this: EditableFieldComponentTestContext, assert) { + this.set("value", ["foo"]); + this.set("onChange", (newValue: string[]) => { + this.set("value", newValue); + }); + + await render(hbs` + + <:default as |F|> + {{F.value}} + + <:editing as |F|> + + F.update + + + + `); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasText("foo"); + + await click(FIELD_TOGGLE_SELECTOR); + await click("button"); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasText("bar"); + }); + + test("onChange only runs if the textInput value has changed", async function (this: EditableFieldComponentTestContext, assert) { + let count = 0; + this.set("onChange", () => count++); + + await render(hbs` + + <:default as |F|> + {{F.value}} + + <:editing as |F|> + + + + `); + + await click(FIELD_TOGGLE_SELECTOR); + + await fillIn("input", "foo"); + await triggerEvent("input", "blur"); + + assert.equal(count, 0, "onChange has not been called"); + + await click(FIELD_TOGGLE_SELECTOR); + + await fillIn("input", "bar"); + await triggerKeyEvent("input", "keydown", "Enter"); + + assert.equal(count, 1, "onChange has been called"); + }); + + test("onChange only runs if the array value has changed", async function (this: EditableFieldComponentTestContext, assert) { + let count = 0; + + this.set("onChange", () => count++); + this.set("newArray", ["foo"]); + + await render(hbs` + + <:default as |F|> + {{F.value}} + + <:editing as |F|> +
+ + +
+ `); + + await click(FIELD_TOGGLE_SELECTOR); + await click(".click-away"); + + assert.equal(count, 0, "onChange has not been called"); + + this.set("newArray", ["bar"]); + + await click(FIELD_TOGGLE_SELECTOR); + await click(".click-away"); + + assert.equal(count, 1, "onChange has been called"); + }); + + test("the input value resets on cancel", async function (this: EditableFieldComponentTestContext, assert) { + await render(hbs` + + <:default as |F|> + {{F.value}} + + <:editing as |F|> + + + + `); + + await click(FIELD_TOGGLE_SELECTOR); + + await fillIn("input", "bar"); + await triggerKeyEvent("input", "keydown", "Escape"); + + assert.dom(EDITABLE_FIELD_SELECTOR).hasText("foo"); + + await click(FIELD_TOGGLE_SELECTOR); + + assert.dom("input").hasValue("foo"); + }); +});