diff --git a/web/app/components/document/sidebar.hbs b/web/app/components/document/sidebar.hbs index dfde65b32..227083165 100644 --- a/web/app/components/document/sidebar.hbs +++ b/web/app/components/document/sidebar.hbs @@ -155,6 +155,7 @@ @isSaving={{this.saveIsRunning}} @formatIsBadge={{true}} @renderOut={{true}} + class="w-[300px]" /> {{else}} diff --git a/web/app/components/inputs/product-select.hbs b/web/app/components/inputs/product-select.hbs index 3ae9612c1..0b2e35cae 100644 --- a/web/app/components/inputs/product-select.hbs +++ b/web/app/components/inputs/product-select.hbs @@ -36,11 +36,11 @@ @offset={{@offset}} @matchAnchorWidth={{@matchAnchorWidth}} @secondaryFilterAttribute="abbreviation" - class="product-select-dropdown-list w-[300px]" + class="product-select-dropdown-list" ...attributes > <:anchor as |dd|> - + + {{! Title }} + - -
- Creating draft in Google Drive... + Title + {{#if this.formErrors.title}} + + {{this.formErrors.title}} + + {{/if}} + + + {{! Summary }} +
+
+ Summary + {{#if this.summaryIsLong}} + + Limit this to 1 or 2 sentences if possible. + + {{/if}}
-
This usually takes - 10-20 seconds.
+
-{{else}} -
-
-
-

Create your - {{@docType}}

-
-
- - Title - - A succinct outline of the idea youʼre proposing. - - -
- - Summary - - One or two sentences - outlining your doc. - {{if this.summaryIsLong "(Just a recommendation)"}} - - -
- -
-
- - Product/Area   - - - - Where your doc should be categorized. - -
-
- -
-
+ {{! Product/area }} +
+ + Product/Area + + + {{#if this.formErrors.productAbbreviation}} + + {{this.formErrors.productAbbreviation}} + + {{/if}} +
- - {{yield - (hash - Error=F.Error - HelperText=F.HelperText - Label=F.Label - isRequired=F.isRequired - isOptional=F.isOptional - ) - }} - - - - - - Contributors - - {{#if this.formErrors.contributors}} - - {{this.formErrors.contributors}} - - {{/if}} - - People to share your doc with. You can always add more later. - - -
-
-
-
-
-

- - Preview -

- - -
-
-
-{{/if}} + {{! Contributors }} + + + + + + Contributors + + + + diff --git a/web/app/components/new/doc-form.ts b/web/app/components/new/doc-form.ts index aa3c498f8..a543ed672 100644 --- a/web/app/components/new/doc-form.ts +++ b/web/app/components/new/doc-form.ts @@ -14,22 +14,14 @@ import FlashService from "ember-cli-flash/services/flash-messages"; import { assert } from "@ember/debug"; import cleanString from "hermes/utils/clean-string"; import { ProductArea } from "hermes/services/product-areas"; +import { next } from "@ember/runloop"; interface DocFormErrors { title: string | null; - summary: string | null; productAbbreviation: string | null; - contributors: string | null; } -const FORM_ERRORS: DocFormErrors = { - title: null, - summary: null, - productAbbreviation: null, - contributors: null, -}; - -const AWAIT_DOC_DELAY = Ember.testing ? 0 : 1000; +const AWAIT_DOC_DELAY = Ember.testing ? 0 : 2000; const AWAIT_DOC_CREATED_MODAL_DELAY = Ember.testing ? 0 : 1500; interface NewDocFormComponentSignature { @@ -54,14 +46,12 @@ export default class NewDocFormComponent extends Component a != null; - return Object.values(this.formErrors).filter(defined).length > 0; + return Object.values(this.formErrors).some((error) => error !== null); + } + + protected get buttonIsActive() { + return !!this.title && !!this.productAbbreviation; } /** - * Sets `formRequirementsMet` and conditionally validates the form. + * Validates the form if `validateEagerly` is true. */ private maybeValidate() { - if (this.title && this.productArea) { - this.formRequirementsMet = true; - } else { - this.formRequirementsMet = false; - } if (this.validateEagerly) { this.validate(); } @@ -118,7 +103,12 @@ export default class NewDocFormComponent extends Component 200) { this.summaryIsLong = true; } else { this.summaryIsLong = false; } + if ("productArea" in formObject) { + this.productArea = formObject["productArea"] as string; + } + + if (e.key === "Enter") { + e.preventDefault(); + this.submit(); + return; + } + + if (this.formErrors.title || this.formErrors.productAbbreviation) { + // Validate once the input values are captured + next("afterRender", () => { + this.validate(); + }); + } + this.maybeValidate(); } @@ -179,11 +181,11 @@ export default class NewDocFormComponent extends Component

Choose a template

- {{! PENDING }} {{! -
- or -
- +
+ or +
+ }}
diff --git a/web/app/components/new/form.hbs b/web/app/components/new/form.hbs new file mode 100644 index 000000000..63eb9edf5 --- /dev/null +++ b/web/app/components/new/form.hbs @@ -0,0 +1,36 @@ +
+ +
+ +

+ {{if @taskIsRunning @taskIsRunningHeadline @headline}} +

+ + + {{#animated-if @taskIsRunning use=this.transition}} +
+ {{@taskIsRunningDescription}} +
+ {{else}} +
+
+ {{yield}} +
+ + + {{/animated-if}} +
diff --git a/web/app/components/new/form.ts b/web/app/components/new/form.ts new file mode 100644 index 000000000..beb235502 --- /dev/null +++ b/web/app/components/new/form.ts @@ -0,0 +1,55 @@ +import Component from "@glimmer/component"; +import Ember from "ember"; +import { TransitionContext } from "ember-animated/."; +import move from "ember-animated/motions/move"; +import { fadeIn, fadeOut } from "ember-animated/motions/opacity"; +import { Resize } from "ember-animated/motions/resize"; +import { easeOutExpo, easeOutQuad } from "hermes/utils/ember-animated/easings"; + +const FORM_RESIZE_DURATION = Ember.testing ? 0 : 1250; + +class HermesFormResize extends Resize { + *animate() { + this.opts.easing = easeOutExpo; + this.opts.duration = FORM_RESIZE_DURATION; + yield* super.animate(); + } +} + +interface NewFormComponentSignature { + Element: HTMLFormElement; + Args: { + taskIsRunning: boolean; + icon: string; + headline: string; + taskIsRunningHeadline: string; + taskIsRunningDescription: string; + buttonText: string; + buttonIsActive: boolean; + }; + Blocks: { + default: []; + }; +} + +export default class NewFormComponent extends Component { + protected motion = HermesFormResize; + + *transition({ insertedSprites, removedSprites }: TransitionContext) { + for (const sprite of insertedSprites) { + sprite.startTranslatedBy(0, -2); + void fadeIn(sprite, { duration: 50 }); + void move(sprite, { easing: easeOutQuad, duration: 350 }); + } + + for (const sprite of removedSprites) { + void fadeOut(sprite, { duration: 0 }); + } + } +} + +declare module "@glint/environment-ember-loose/registry" { + export default interface Registry { + "New::Form": typeof NewFormComponent; + } +} diff --git a/web/app/components/new/project-form.hbs b/web/app/components/new/project-form.hbs index b7cc614ea..8c384fd9f 100644 --- a/web/app/components/new/project-form.hbs +++ b/web/app/components/new/project-form.hbs @@ -1,49 +1,46 @@ -
-
-

Start a project

-
-
- - Title - {{#if this.errorIsShown}} - - Title is required. - - {{/if}} - + + + Title + + {{#if this.titleErrorIsShown}} + + Title is required. + + {{/if}} + - - Description - + + Description + - {{! TODO: Add Jira integration }} + {{! TODO: Add Jira integration }} -
- - + diff --git a/web/app/components/new/project-form.ts b/web/app/components/new/project-form.ts index d8d4b4033..7ee83a761 100644 --- a/web/app/components/new/project-form.ts +++ b/web/app/components/new/project-form.ts @@ -9,54 +9,69 @@ import { task } from "ember-concurrency"; import FetchService from "hermes/services/fetch"; import cleanString from "hermes/utils/clean-string"; -interface NewProjectFormComponentSignature { - Args: {}; -} +interface NewProjectFormComponentSignature {} export default class NewProjectFormComponent extends Component { @service("fetch") declare fetchSvc: FetchService; @service declare router: RouterService; @service declare flashMessages: FlashMessageService; + /** + * Whether the project is being created, or in the process of + * transitioning to the project screen after successful creation. + * Used by the `New::Form` component for conditional rendering. + * Set true when the createProject task is running. + * Reverted only if an error occurs. + */ + @tracked protected projectIsBeingCreated = false; + @tracked protected title: string = ""; @tracked protected description: string = ""; + @tracked protected titleErrorIsShown = false; - @tracked protected formIsValid = false; - @tracked protected errorIsShown = false; - + /** + * The action to attempt a form submission. + * If the form is valid, the createProject task is run. + */ @action maybeSubmitForm(event?: SubmitEvent) { - if (event) { - event.preventDefault(); - } - - this.validateForm(); + event?.preventDefault(); + this.validate(); - if (this.formIsValid) { - this.createProject.perform(); + if (!this.titleErrorIsShown) { + void this.createProject.perform(); } } - private validateForm() { - this.errorIsShown = this.title.length === 0; - this.formIsValid = this.title.length > 0; + private validate() { + this.titleErrorIsShown = this.title.length === 0; } - @action protected onKeydown(e: KeyboardEvent) { - if (e.key === "Enter") { - // Replace newline function with submit action - e.preventDefault(); + /** + * The action run on title- and description-input keydown. + * If the key is Enter, a form submission is attempted. + * If the title error is shown, validation is run eagerly. + */ + @action protected onKeydown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); this.maybeSubmitForm(); } - if (this.errorIsShown) { - // Validate once the input value are captured + if (this.titleErrorIsShown) { + // Validate once the input values are captured next("afterRender", () => { - this.validateForm(); + this.validate(); }); } } + /** + * The task that creates a project and, if successful, + * transitions to it. On error, displays a FlashMessage + * and reverts the `projectIsBeingCreated` state. + */ private createProject = task(async () => { try { + this.projectIsBeingCreated = true; const project = await this.fetchSvc .fetch("/api/v1/projects", { method: "POST", @@ -69,7 +84,6 @@ export default class NewProjectFormComponent extends Component !this.element.querySelector(PRODUCT_ERROR)); + + assert.dom(PRODUCT_ERROR).doesNotExist(); + + await fillIn(TITLE_INPUT, ""); + + // Trigger a keydown event to start validation + await triggerKeyEvent(TITLE_INPUT, "keydown", "Escape"); + + assert.dom(TITLE_ERROR).exists(); + }); + + test("the button changes color when the form is valid", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + this.server.createList("product", 1); + + await visit("/new/doc?docType=RFC"); + + assert.dom(SECONDARY_CREATE_BUTTON).exists(); + assert.dom(PRIMARY_CREATE_BUTTON).doesNotExist(); + + await fillIn(TITLE_INPUT, "Foo"); + await click(PRODUCT_SELECT_TOGGLE); + await click(FIRST_PRODUCT_SELECT_ITEM_BUTTON); + + assert.dom(SECONDARY_CREATE_BUTTON).doesNotExist(); + assert.dom(PRIMARY_CREATE_BUTTON).exists(); + }); + + test("it shows a message if the summary is more than 200 characters", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + this.server.createList("product", 1); + + await visit("/new/doc?docType=RFC"); + + assert.dom("[data-test-summary-length-warning]").doesNotExist(); + + await fillIn(SUMMARY_INPUT, "A".repeat(201)); + + // Trigger a keydown event to start validation + await triggerKeyEvent(TITLE_INPUT, "keydown", "A"); + + assert.dom("[data-test-summary-warning]").exists(); + }); }); diff --git a/web/tests/acceptance/authenticated/new/project-test.ts b/web/tests/acceptance/authenticated/new/project-test.ts index f84bf0b01..9a27dc3a8 100644 --- a/web/tests/acceptance/authenticated/new/project-test.ts +++ b/web/tests/acceptance/authenticated/new/project-test.ts @@ -7,15 +7,20 @@ import { Response } from "miragejs"; import { module, test } from "qunit"; const PROJECT_FORM = "[data-test-project-form]"; +const HEADLINE = "[data-test-form-headline]"; +const ICON = "[data-test-feature-icon]"; const TITLE_INPUT = `${PROJECT_FORM} [data-test-title]`; const DESCRIPTION_INPUT = `${PROJECT_FORM} [data-test-description]`; const SUBMIT_BUTTON = `${PROJECT_FORM} [data-test-submit]`; +const SECONDARY_CREATE_BUTTON = `${PROJECT_FORM} .hds-button--color-secondary`; +const PRIMARY_CREATE_BUTTON = `${PROJECT_FORM} .hds-button--color-primary`; const TITLE_ERROR = `${PROJECT_FORM} [data-test-title-error]`; const FLASH_MESSAGE = "[data-test-flash-notification]"; +const TASK_IS_RUNNING_DESCRIPTION = "[data-test-task-is-running-description]"; interface AuthenticatedNewProjectRouteTestContext extends MirageTestContext {} -module("Acceptance | authenticated/new/project", function (hooks) { +module("Acceptance | authenticated/new/project-form", function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -72,6 +77,28 @@ module("Acceptance | authenticated/new/project", function (hooks) { assert.dom(TITLE_ERROR).hasText("Title is required."); }); + test("it shows a loading screen while a project is being created", async function (this: AuthenticatedNewProjectRouteTestContext, assert) { + await visit("new/project"); + + assert.dom(HEADLINE).hasText("Start a project"); + assert.dom(ICON).hasAttribute("data-test-icon", "grid"); + assert.dom(TASK_IS_RUNNING_DESCRIPTION).doesNotExist(); + + await fillIn(TITLE_INPUT, "The Foo Project"); + + const clickPromise = click(SUBMIT_BUTTON); + + await waitFor(TASK_IS_RUNNING_DESCRIPTION); + + assert.dom(HEADLINE).hasText("Creating project..."); + assert.dom(ICON).hasAttribute("data-test-icon", "running"); + assert + .dom(TASK_IS_RUNNING_DESCRIPTION) + .hasText("This shouldn't take long."); + + await clickPromise; + }); + test("it shows an error if creating the project fails", async function (this: AuthenticatedNewProjectRouteTestContext, assert) { this.server.post("/projects", () => { return new Response(500, {}, {}); @@ -85,4 +112,16 @@ module("Acceptance | authenticated/new/project", function (hooks) { await waitFor(FLASH_MESSAGE); assert.dom(FLASH_MESSAGE).containsText("Error creating project"); }); + + test("the button changes color when the form is valid", async function (this: AuthenticatedNewProjectRouteTestContext, assert) { + await visit("/new/project"); + + assert.dom(SECONDARY_CREATE_BUTTON).exists(); + assert.dom(PRIMARY_CREATE_BUTTON).doesNotExist(); + + await fillIn(TITLE_INPUT, "Foo"); + + assert.dom(SECONDARY_CREATE_BUTTON).doesNotExist(); + assert.dom(PRIMARY_CREATE_BUTTON).exists(); + }); }); diff --git a/web/types/glint/index.d.ts b/web/types/glint/index.d.ts index c9961148f..a03acbe28 100644 --- a/web/types/glint/index.d.ts +++ b/web/types/glint/index.d.ts @@ -42,6 +42,7 @@ import { AnimatedIfCurly } from "ember-animated/components/animated-if"; import { FlashMessageComponent } from "ember-cli-flash/flash-message"; import { HdsFormErrorComponent } from "hds/form/error"; import PowerSelectMultiple from "ember-power-select/components/power-select-multiple"; +import { HdsFormLabelComponent } from "hds/form/label"; declare module "@glint/environment-ember-loose/registry" { export default interface Registry { @@ -79,6 +80,7 @@ declare module "@glint/environment-ember-loose/registry" { "Hds::Form::Textarea::Field": HdsFormTextareaFieldComponent; "Hds::Form::Toggle::Base": HdsFormToggleBaseComponent; "Hds::Form::Field": HdsFormFieldComponent; + "Hds::Form::Label": HdsFormLabelComponent; "Hds::Toast": HdsToastComponent; "Hds::Badge": HdsBadgeComponent; "Hds::ButtonSet": HdsButtonSetComponent; diff --git a/web/types/hds/form/label.d.ts b/web/types/hds/form/label.d.ts new file mode 100644 index 000000000..554ee719b --- /dev/null +++ b/web/types/hds/form/label.d.ts @@ -0,0 +1,18 @@ +// https://helios.hashicorp.design/components/form/primitives?tab=code#formlabel-2 + +import { ComponentLike } from "@glint/template"; + +interface HdsFormLabelComponentSignature { + Element: HTMLLabelElement; + Args: { + controlId?: string; + isRequired?: boolean; + isOptional?: boolean; + }; + Blocks: { + default: []; + }; +} + +export type HdsFormLabelComponent = + ComponentLike;