diff --git a/web/app/components/doc/tile-medium.hbs b/web/app/components/doc/tile-medium.hbs index 3dab213f6..751afe7ab 100644 --- a/web/app/components/doc/tile-medium.hbs +++ b/web/app/components/doc/tile-medium.hbs @@ -39,8 +39,10 @@ > {{! Primary click area }}

diff --git a/web/app/components/doc/tile-medium.ts b/web/app/components/doc/tile-medium.ts index f1b4e5710..c80486e62 100644 --- a/web/app/components/doc/tile-medium.ts +++ b/web/app/components/doc/tile-medium.ts @@ -4,7 +4,6 @@ import { HermesDocument } from "hermes/types/document"; import { inject as service } from "@ember/service"; import FetchService from "hermes/services/fetch"; import ConfigService from "hermes/services/config"; -import { DEFAULT_FILTERS } from "hermes/services/active-filters"; interface DocTileMediumComponentSignature { Element: HTMLAnchorElement; @@ -12,6 +11,13 @@ interface DocTileMediumComponentSignature { doc: RelatedHermesDocument | HermesDocument; avatarIsLoading?: boolean; + /** + * Whether the tile is part of a list that can be reordered. + * If true, we extend the hover/focus affordance into the gutter + * where the grab handle will be. + */ + canBeReordered?: boolean; + /** * The search query associated with the current view. * Used to highlight search terms in the document title. diff --git a/web/app/components/floating-u-i/content.ts b/web/app/components/floating-u-i/content.ts index 1c96d717f..7c58024e4 100644 --- a/web/app/components/floating-u-i/content.ts +++ b/web/app/components/floating-u-i/content.ts @@ -30,6 +30,7 @@ interface FloatingUIContentSignature { renderOut?: boolean; offset?: OffsetOptions; matchAnchorWidth?: MatchAnchorWidthOptions; + hide: () => void; }; Blocks: { default: []; @@ -65,7 +66,7 @@ export default class FloatingUIContent extends Component { let _placement = placement || "bottom-start"; + /** + * If anchor exists within a div that's being dragged, hide the content + * to prevent the dropdown from remaining open after its parent is dragged. + * The `is-dragging` class is added by Pragmatic Drag and Drop. + */ + const elementBeingDragged = document.querySelector(".is-dragging"); + + if (elementBeingDragged && elementBeingDragged.contains(anchor)) { + this.args.hide(); + return; + } + computePosition(anchor, content, { platform, placement: _placement as Placement, @@ -95,7 +108,10 @@ export default class FloatingUIContent extends Component {{yield diff --git a/web/app/components/project/index.hbs b/web/app/components/project/index.hbs index ce4ffcf10..7f6fd3b8b 100644 --- a/web/app/components/project/index.hbs +++ b/web/app/components/project/index.hbs @@ -160,6 +160,8 @@ @items={{this.hermesDocuments}} @shouldAnimate={{this.shouldAnimate}} @motion={{this.resourceListContainerMotion}} + @isReadOnly={{not this.projectIsActive}} + @onSave={{this.saveDocumentOrder}} class="mt-7" > <:header> @@ -170,7 +172,13 @@ <:item as |i|> - + @@ -196,6 +204,8 @@ @items={{this.externalLinks}} @shouldAnimate={{this.shouldAnimate}} @motion={{this.resourceListContainerMotion}} + @isReadOnly={{not this.projectIsActive}} + @onSave={{this.saveLinkOrder}} > <:header> @@ -204,7 +214,13 @@ <:item as |i|>
@@ -230,12 +246,12 @@

{{link.name}}

{{link.url}}
diff --git a/web/app/components/project/index.ts b/web/app/components/project/index.ts index 3e87b785b..f0be6d5d6 100644 --- a/web/app/components/project/index.ts +++ b/web/app/components/project/index.ts @@ -5,6 +5,7 @@ import { RelatedExternalLink, RelatedHermesDocument, RelatedResource, + RelatedResourcesScope, } from "../related-resources"; import { inject as service } from "@ember/service"; import FetchService from "hermes/services/fetch"; @@ -330,6 +331,69 @@ export default class ProjectIndexComponent extends Component + {{! Drop affordance }} + {{#if this.dragHasEntered}} +
+
+ {{/if}} + + {{yield + (hash + moveToTop=this.moveToTop + moveUp=this.moveUp + moveDown=this.moveDown + moveToBottom=this.moveToBottom + ) + }} + + + +{{! + A hidden div rendered only if the list is interactive. + Used to configure the drag-and-drop behavior in a state-reactive way, + refreshing or initializing when the list changes from read only to interactive. +}} +{{#unless @isReadOnly}} + {{#in-element (html-element ".ember-application") insertBefore=null}} +
+ {{/in-element}} +{{/unless}} + +{{! Drag preview }} +{{#if this.isDragging}} + {{! Target the customNativeDragPreview container }} + {{! This element will be available by the time `html-element` runs }} + {{#in-element (html-element (concat "#" this.id)) insertBefore=null}} +
+
+ {{this.itemTitle}} + {{#if this.docNumber}} + + {{this.docNumber}} + + {{/if}} +
+
+ {{/in-element}} +{{/if}} diff --git a/web/app/components/project/resource-list-item.ts b/web/app/components/project/resource-list-item.ts new file mode 100644 index 000000000..03cd61daa --- /dev/null +++ b/web/app/components/project/resource-list-item.ts @@ -0,0 +1,344 @@ +import { action } from "@ember/object"; +import Component from "@glimmer/component"; +import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + dropTargetForElements, + ElementDropTargetEventBasePayload, + ElementDropTargetGetFeedbackArgs, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + attachClosestEdge, + Edge, + extractClosestEdge, +} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { assert } from "@ember/debug"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { tracked } from "@glimmer/tracking"; +import { RelatedResource } from "../related-resources"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { announce } from "@atlaskit/pragmatic-drag-and-drop-live-region"; +import { guidFor } from "@ember/object/internals"; +import { schedule } from "@ember/runloop"; + +enum Edges { + Top = "top", + Bottom = "bottom", +} + +interface ProjectResourceListItemComponentSignature { + Element: HTMLLIElement; + Args: { + isReadOnly: boolean; + item: RelatedResource; + itemCount: number; + index: number; + onSave: (currentIndex: number, newIndex: number) => void; + }; + Blocks: { + default: [ + { + moveToTop: () => void; + moveUp: () => void; + moveDown: () => void; + moveToBottom: () => void; + }, + ]; + }; +} + +export default class ProjectResourceListItemComponent extends Component { + /** + * A unique identifier used for the `customNativeDragPreview` container. + * Used by `in-element` to render the drag preview in the correct location. + */ + protected id = guidFor(this); + + /** + * Whether the item is currently being dragged. + * Set true `onGenerateDragPreview` and false `onDrop`. + * Used to conditionally render the drag preview. + */ + @tracked protected isDragging = false; + + /** + * Whether the item is currently being dragged over. + * Set true `onDragEnter` and false `onDragLeave`. + * Used to conditionally render the drop indicator. + */ + @tracked protected dragHasEntered = false; + + /** + * The closest edge of the drop target element. + * Used to determine where the drop indicator should be rendered. + */ + @tracked protected closestEdge: Edge | null = null; + + /** + * The list item element. Registered on render and used in drag-and-drop + * functions and scoped `querySelector` calls. + */ + @tracked protected el: HTMLElement | null = null; + + /** + * The drag handle element. Targeted for focus after a drop. + */ + @tracked protected _dragHandle: HTMLButtonElement | null = null; + + /** + * The title of the item, whether it's a document or a resource. + * Used in the `customNativeDragPreview`. + */ + protected get itemTitle() { + if ("title" in this.args.item) { + return this.args.item.title; + } else { + return this.args.item.name; + } + } + + /** + * The document number of the item, if it's a document. + * Used in the `customNativeDragPreview`. + */ + protected get docNumber() { + if ("documentNumber" in this.args.item) { + return this.args.item.documentNumber; + } else { + return null; + } + } + + /** + * An asserted-true getter for the drag handle element. + */ + protected get dragHandle() { + assert("dragHandle must exist", this._dragHandle); + return this._dragHandle; + } + + /** + * The action to announce the movement of an item to screen readers. + * Called when an item is moved up, down, to the top, or to the bottom. + */ + private announceMovement(direction: string) { + announce(`${this.itemTitle} moved ${direction}`); + } + + private scheduleFocusDragHandle() { + schedule("afterRender", () => { + this.dragHandle.focus(); + }); + } + + /** + * The action to register the list item. + * Called on render and used as a target for drag-and-drop functions. + */ + @action protected registerElement(element: HTMLElement) { + this.el = element; + } + + /** + * The action to move an item to the top of the list. + * Called on click of the "move to top" button. + */ + @action protected moveToTop() { + this.args.onSave(this.args.index, 0); + this.announceMovement("to top"); + this.scheduleFocusDragHandle(); + } + + /** + * The action to move an item up in the list. + * Called on click of the "move up" button. + */ + @action protected moveUp() { + this.args.onSave(this.args.index, this.args.index - 1); + this.announceMovement("up"); + this.scheduleFocusDragHandle(); + } + + /** + * The action to move an item down in the list. + * Called on click of the "move down" button. + */ + @action protected moveDown() { + this.args.onSave(this.args.index, this.args.index + 1); + this.announceMovement("down"); + this.scheduleFocusDragHandle(); + } + + /** + * The action to move an item to the bottom of the list. + * Called on click of the "move to bottom" button. + */ + @action protected moveToBottom() { + this.args.onSave(this.args.index, this.args.itemCount - 1); + this.announceMovement("to bottom"); + this.scheduleFocusDragHandle(); + } + + private isHoveringSameParent(e: ElementDropTargetEventBasePayload) { + const sourceElement = e.source.element; + const selfElement = e.self.element; + + const sourceElementParent = sourceElement.parentElement; + const selfElementParent = selfElement.parentElement; + + return sourceElementParent === selfElementParent; + } + + /** + * The action to configure the drag-and-drop functionality. + * Called on render if the list is interactive. Configures the drag-and-drop + * functionality for the list item. + */ + @action protected configureDragAndDrop() { + assert("element must exist", this.el); + + const element = this.el; + this._dragHandle = this.el.querySelector(`.drag-handle`); + + combine( + draggable({ + element, + dragHandle: this.dragHandle, + onGenerateDragPreview: ({ nativeSetDragImage }) => { + this.isDragging = true; + setCustomNativeDragPreview({ + nativeSetDragImage, + render: ({ container }) => { + // Create a target for `in-element` + container.id = this.id; + }, + }); + }, + onDrop: () => { + this.isDragging = false; + + schedule("afterRender", () => { + this.dragHandle.focus(); + }); + }, + getInitialData: () => { + return { + index: this.args.index, + }; + }, + }), + dropTargetForElements({ + element, + getData(e: ElementDropTargetGetFeedbackArgs) { + return attachClosestEdge( + {}, + { + element, + input: e.input, + allowedEdges: [Edges.Top, Edges.Bottom], + }, + ); + }, + canDrop: (e: ElementDropTargetGetFeedbackArgs) => { + return e.source.element !== element; + }, + onDrag: (e: ElementDropTargetEventBasePayload) => { + const isSource = e.source.element === element; + if (isSource) { + this.closestEdge = null; + return; + } + + const closestEdge = extractClosestEdge(e.self.data); + const sourceIndex = e.source.data["index"]; + + assert( + "sourceIndex must be a number", + typeof sourceIndex === "number", + ); + + const dropTarget = e.location.current.dropTargets[0]; + + if (dropTarget) { + const dataIndex = dropTarget.element.getAttribute("data-index"); + + assert("data-index must exist", dataIndex); + + const index = parseInt(dataIndex, 10); + const isItemBeforeSource = index === sourceIndex - 1; + const isItemAfterSource = index === sourceIndex + 1; + + const isDropIndicatorHidden = + (isItemBeforeSource && closestEdge === Edges.Bottom) || + (isItemAfterSource && closestEdge === Edges.Top); + + if (isDropIndicatorHidden) { + this.closestEdge = null; + return; + } else { + this.closestEdge = closestEdge; + } + } else { + this.closestEdge = null; + return; + } + }, + onDragEnter: (e: ElementDropTargetEventBasePayload) => { + const isHoveringSelf = e.source.element === element; + + if (!isHoveringSelf && this.isHoveringSameParent(e)) { + this.dragHasEntered = true; + } + }, + onDragLeave: () => { + this.dragHasEntered = false; + this.closestEdge = null; + }, + onDrop: (e: ElementDropTargetEventBasePayload) => { + if (!this.isHoveringSameParent(e)) { + return; + } + + const { data } = e.source; + const index = data["index"]; + + assert("index must be a number", typeof index === "number"); + + const dropTargetElement = e.location.current.dropTargets[0]?.element; + let newIndex = parseInt( + dropTargetElement?.getAttribute("data-index") ?? "", + 10, + ); + const closestEdge = + dropTargetElement?.getAttribute("data-closest-edge"); + + const isDraggingDown = index < newIndex; + const isDraggingUp = index > newIndex; + + if (closestEdge === "bottom" && isDraggingUp) { + newIndex += 1; + } else if (closestEdge === "top" && isDraggingDown) { + newIndex -= 1; + } + + this.args.onSave(index, newIndex); + + // Announce to screen readers + announce( + `${this.itemTitle} moved from position ${index + 1} to ${ + newIndex + 1 + }`, + ); + + this.dragHasEntered = false; + this.closestEdge = null; + }, + }), + ); + } +} + +declare module "@glint/environment-ember-loose/registry" { + export default interface Registry { + "Project::ResourceListItem": typeof ProjectResourceListItemComponent; + } +} diff --git a/web/app/components/project/resource-list.hbs b/web/app/components/project/resource-list.hbs index 7e52b9d99..0db36c051 100644 --- a/web/app/components/project/resource-list.hbs +++ b/web/app/components/project/resource-list.hbs @@ -11,6 +11,7 @@ {{/animated-if}}
+ {{! @glint-ignore }} {{#animated-if (gt @items.length 0) @@ -27,9 +28,29 @@ finalRemoval=true as |item i| }} -
  • - {{yield (hash item=item index=i) to="item"}} -
  • + + {{yield + (hash + item=item + index=i + isReadOnly=@isReadOnly + canMoveUp=(gt i 0) + canMoveDown=(lt i (add @items.length -1)) + moveToTop=r.moveToTop + moveUp=r.moveUp + moveDown=r.moveDown + moveToBottom=r.moveToBottom + ) + to="item" + }} + {{/animated-each}}
    diff --git a/web/app/components/project/resource-list.ts b/web/app/components/project/resource-list.ts index 114a4e054..fd42bb34f 100644 --- a/web/app/components/project/resource-list.ts +++ b/web/app/components/project/resource-list.ts @@ -19,6 +19,8 @@ interface ProjectResourceListComponentSignature { items: RelatedResource[]; shouldAnimate: boolean; motion: unknown; + isReadOnly: boolean; + onSave: (currentIndex: number, newIndex: number) => void; }; Blocks: { header: []; @@ -26,6 +28,13 @@ interface ProjectResourceListComponentSignature { { item: RelatedResource; index: number; + isReadOnly: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + moveToTop: () => void; + moveUp: () => void; + moveDown: () => void; + moveToBottom: () => void; }, ]; }; diff --git a/web/app/components/project/resource.hbs b/web/app/components/project/resource.hbs index 1e70b692d..8d8ffd7e4 100644 --- a/web/app/components/project/resource.hbs +++ b/web/app/components/project/resource.hbs @@ -3,6 +3,35 @@ {{yield}} {{#unless @isReadOnly}} + + <:anchor as |dd|> +
    + + + +
    + + <:item as |dd|> + + + {{dd.attrs.label}} + + +
    + ; isReadOnly?: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + moveToTop: () => void; + moveUp: () => void; + moveDown: () => void; + moveToBottom: () => void; }; Blocks: { default: []; }; } -export default class ProjectResourceComponent extends Component {} +export default class ProjectResourceComponent extends Component { + /** + * The items to display in the overflow menu according + * to the resource's position in the list. + */ + protected get sortOrderMenuItems() { + const moveToTop = { + label: MoveOptionLabel.Top, + icon: MoveOptionIcon.Top, + action: () => this.args.moveToTop(), + }; + + const moveUp = { + label: MoveOptionLabel.Up, + icon: MoveOptionIcon.Up, + action: () => this.args.moveUp(), + }; + + const moveDown = { + label: MoveOptionLabel.Down, + icon: MoveOptionIcon.Down, + action: () => this.args.moveDown(), + }; + + const moveToBottom = { + label: MoveOptionLabel.Bottom, + icon: MoveOptionIcon.Bottom, + action: () => this.args.moveToBottom(), + }; + + const { canMoveUp, canMoveDown } = this.args; + + const items = [ + canMoveUp ? moveToTop : null, + canMoveUp ? moveUp : null, + canMoveDown ? moveDown : null, + canMoveDown ? moveToBottom : null, + ]; + + return items.compact(); + } +} declare module "@glint/environment-ember-loose/registry" { export default interface Registry { diff --git a/web/app/styles/app.scss b/web/app/styles/app.scss index e468d5c48..d0e277cd3 100644 --- a/web/app/styles/app.scss +++ b/web/app/styles/app.scss @@ -118,7 +118,7 @@ h1 { } .divided-list { - li { + > li { @apply border-t border-t-transparent; } @@ -138,11 +138,11 @@ h1 { } .visible-with-group { - @apply group-focus-within:visible group-hover:visible; + @apply group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100; &:hover, &:focus-within { - @apply visible; + @apply visible opacity-100; } } diff --git a/web/app/styles/components/doc/tile.scss b/web/app/styles/components/doc/tile.scss index 8ef92e6c6..93a95cacc 100644 --- a/web/app/styles/components/doc/tile.scss +++ b/web/app/styles/components/doc/tile.scss @@ -19,3 +19,36 @@ } } } + +.divided-list > li { + &:hover, + &:focus-within { + .drag-handle-container.shaded::before { + content: ""; + @apply absolute top-0 bottom-0 left-0 right-0; + @apply bg-color-surface-faint; + } + } + + &:not(:last-child) { + &:hover, + &:focus-within { + .drag-handle-container { + @apply border-b-color-border-faint; + } + } + } + + &:not(:first-child) { + &:hover, + &:focus-within { + .drag-handle-container { + @apply border-t-color-border-faint; + } + } + } + + .drag-handle-container { + @apply border-t border-b border-t-transparent border-b-transparent; + } +} diff --git a/web/mirage/factories/project.ts b/web/mirage/factories/project.ts index f7b3624b7..90a289b98 100644 --- a/web/mirage/factories/project.ts +++ b/web/mirage/factories/project.ts @@ -1,4 +1,4 @@ -import { Factory, ModelInstance, Server } from "miragejs"; +import { Factory, ModelInstance } from "miragejs"; import { HermesProject } from "hermes/types/project"; import { TEST_USER_EMAIL } from "../utils"; diff --git a/web/mirage/factories/related-hermes-document.ts b/web/mirage/factories/related-hermes-document.ts index ebcfa92ab..8b68d1e19 100644 --- a/web/mirage/factories/related-hermes-document.ts +++ b/web/mirage/factories/related-hermes-document.ts @@ -20,4 +20,28 @@ export default Factory.extend({ summary() { return `Summary for ${this.title}`; }, + + // @ts-ignore - Bug https://github.com/miragejs/miragejs/issues/1052 + afterCreate(relatedHermesDocument, server) { + const { id } = relatedHermesDocument; + const doc = server.schema.document.find(id); + + if (!doc) { + server.create("document", { + id, + objectID: id, + title: relatedHermesDocument.title, + docType: relatedHermesDocument.documentType, + docNumber: relatedHermesDocument.documentNumber, + owners: relatedHermesDocument.owners, + product: relatedHermesDocument.product, + status: relatedHermesDocument.status, + _snippetResult: { + content: { + value: relatedHermesDocument.summary, + }, + }, + }); + } + }, }); diff --git a/web/package.json b/web/package.json index 3ac42f256..7d7c51dbf 100644 --- a/web/package.json +++ b/web/package.json @@ -132,8 +132,11 @@ "edition": "octane" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.3", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@atlaskit/pragmatic-drag-and-drop-live-region": "^1.0.4", "@csstools/postcss-sass": "^5.0.1", - "@floating-ui/dom": "^1.2.4", + "@floating-ui/dom": "^1.6.3", "@hashicorp/design-system-components": "^3.1.0", "@hashicorp/ember-flight-icons": "^4.0.2", "@types/sinon": "^10.0.13", diff --git a/web/tests/acceptance/authenticated/projects/project-test.ts b/web/tests/acceptance/authenticated/projects/project-test.ts index f4c7aa037..888fee198 100644 --- a/web/tests/acceptance/authenticated/projects/project-test.ts +++ b/web/tests/acceptance/authenticated/projects/project-test.ts @@ -1,7 +1,15 @@ import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support"; import { authenticateSession } from "ember-simple-auth/test-support"; import { module, test } from "qunit"; -import { click, currentURL, fillIn, visit, waitFor } from "@ember/test-helpers"; +import { + click, + currentURL, + fillIn, + findAll, + triggerEvent, + visit, + waitFor, +} from "@ember/test-helpers"; import { getPageTitle } from "ember-page-title/test-support"; import { setupApplicationTest } from "ember-qunit"; import { ProjectStatus } from "hermes/types/project-status"; @@ -20,6 +28,12 @@ import { import MockDate from "mockdate"; import { DEFAULT_MOCK_DATE } from "hermes/utils/mockdate/dates"; import RecentlyViewedService from "hermes/services/recently-viewed"; +import { + RelatedExternalLink, + RelatedHermesDocument, +} from "hermes/components/related-resources"; +import { assert as emberAssert } from "@ember/debug"; +import { MoveOptionLabel } from "hermes/components/project/resource"; const GLOBAL_SEARCH_INPUT = "[data-test-global-search-input]"; const GLOBAL_SEARCH_PROJECT_HIT = "[data-test-project-hit]"; @@ -72,6 +86,12 @@ const EXTERNAL_LINK_OVERFLOW_MENU_BUTTON = `${EXTERNAL_LINK_LIST} [data-test-ove const OVERFLOW_MENU_EDIT = "[data-test-overflow-menu-action='edit']"; const OVERFLOW_MENU_REMOVE = "[data-test-overflow-menu-action='remove']"; +// Drag and drop +const DRAG_HANDLE = "[data-test-drag-handle]"; +const DOC_DRAG_HANDLE = `${DOCUMENT_LIST_ITEM} ${DRAG_HANDLE}`; +const LINK_DRAG_HANDLE = `${EXTERNAL_LINK_LIST} ${DRAG_HANDLE}`; +const MOVE_OPTION = "[data-test-move-option]"; + const DOCUMENT_LINK = "[data-test-document-link]"; const DOCUMENT_TITLE = "[data-test-document-title]"; const DOCUMENT_SUMMARY = "[data-test-document-summary]"; @@ -419,23 +439,184 @@ module("Acceptance | authenticated/projects/project", function (hooks) { assert.equal(projectDocuments.length, 0); }); - test("documents can only be removed if the project is active", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + test("you can reorder documents", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + // The project starts with a related document; add two more. + this.server.createList("related-hermes-document", 2); + + const project = this.server.schema.projects.first(); + + project.update({ + hermesDocuments: this.server.schema.relatedHermesDocument + .all() + .models.map((doc: { attrs: RelatedHermesDocument }, index: number) => ({ + ...doc.attrs, + sortOrder: index, + })), + }); + + await visit("/projects/1"); + + assert.dom(DOCUMENT_LIST_ITEM).exists({ count: 3 }); + + let [first, second, third]: Array = [ + undefined, + undefined, + undefined, + ]; + + const captureItems = () => { + [first, second, third] = findAll(DOCUMENT_LIST_ITEM); + emberAssert("first doc exists", first); + emberAssert("second doc exists", second); + emberAssert("third doc exists", third); + }; + + const assertReordered = (expectedOrder: string[]) => { + captureItems(); + + assert.dom(first).containsText(expectedOrder[0] as string); + assert.dom(second).containsText(expectedOrder[1] as string); + assert.dom(third).containsText(expectedOrder[2] as string); + + const projectDocuments = + this.server.schema.projects.first().attrs.hermesDocuments; + + const relatedDocIDs = projectDocuments.map( + (doc: RelatedHermesDocument) => doc.googleFileID, + ); + + assert.deepEqual(relatedDocIDs, expectedOrder); + }; + + // Assert the initial order + assertReordered(["doc-0", "doc-1", "doc-2"]); + + // Open the reorder menu + await click(DOC_DRAG_HANDLE); + + // move the first document to the bottom + const moveToBottom = findAll(MOVE_OPTION)[1]; + emberAssert("second move option exists", moveToBottom); + assert.dom(moveToBottom).containsText(MoveOptionLabel.Bottom); + await click(moveToBottom); + + assertReordered(["doc-1", "doc-2", "doc-0"]); + + // Open the reorder menu of `doc-1` + await click(DOC_DRAG_HANDLE); + + // Move it to the bottom + const bottomOption = findAll(MOVE_OPTION)[1]; + emberAssert("bottom move option exists", bottomOption); + + await click(bottomOption); + + assertReordered(["doc-2", "doc-0", "doc-1"]); + + // Open the reorder menu of `doc-1` to test the top option + + let dragHandle = findAll(DRAG_HANDLE)[2]; + emberAssert("second move option exists", dragHandle); + + // Move it to the top + await click(dragHandle); + await click(MOVE_OPTION); + + assertReordered(["doc-1", "doc-2", "doc-0"]); + + // Move `doc-2` to the top + dragHandle = findAll(DRAG_HANDLE)[1]; + emberAssert("second move option exists", dragHandle); + + await click(dragHandle); + await click(MOVE_OPTION); + + assertReordered(["doc-2", "doc-1", "doc-0"]); + }); + + test("you can reorder external links", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + this.server.createList("related-external-link", 2); + + let project = this.server.schema.projects.first(); + + project.update({ + externalLinks: this.server.schema.relatedExternalLinks + .all() + .models.map((link: { attrs: RelatedExternalLink }, index: number) => ({ + ...link.attrs, + sortOrder: index, + })), + }); + + await visit("/projects/1"); + + assert.dom(EXTERNAL_LINK).exists({ count: 2 }); + + let [first, second]: Array = [undefined, undefined]; + + const captureItems = () => { + [first, second] = findAll(EXTERNAL_LINK); + emberAssert("first link exists", first); + emberAssert("second link exists", second); + }; + + const assertReordered = (expectedOrder: string[]) => { + captureItems(); + + assert.dom(first).containsText(expectedOrder[0] as string); + assert.dom(second).containsText(expectedOrder[1] as string); + + project = this.server.schema.projects.first(); + const projectLinks = project.externalLinks; + + const relatedLinkIDs = projectLinks.map((link: RelatedExternalLink) => { + return this.server.schema.relatedExternalLinks.findBy({ + url: link.url, + }).id; + }); + + assert.deepEqual(relatedLinkIDs, expectedOrder); + }; + + // Assert the initial order + assertReordered(["0", "1"]); + + // Open the reorder menu + await click(LINK_DRAG_HANDLE); + + // move the first link to the bottom + + const moveToBottom = findAll(MOVE_OPTION)[1]; + emberAssert("second move option exists", moveToBottom); + + await click(moveToBottom); + + assertReordered(["1", "0"]); + + // We don't need to test this further; the underlying method + // has already been verified in the doc-reordering test + }); + + test("documents can only be removed or reordered if the project is active", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { await visit("/projects/1"); assert.dom(DOCUMENT_LIST_ITEM).exists(); assert.dom(DOCUMENT_OVERFLOW_MENU_BUTTON).exists(); + assert.dom(DOC_DRAG_HANDLE).exists(); await click(STATUS_TOGGLE); await click(COMPLETED_STATUS_ACTION); assert.dom(DOCUMENT_LIST_ITEM).exists(); assert.dom(DOCUMENT_OVERFLOW_MENU_BUTTON).doesNotExist(); + assert.dom(DOC_DRAG_HANDLE).doesNotExist(); await click(STATUS_TOGGLE); await click(ARCHIVED_STATUS_ACTION); assert.dom(DOCUMENT_LIST_ITEM).exists(); assert.dom(DOCUMENT_OVERFLOW_MENU_BUTTON).doesNotExist(); + assert.dom(DOC_DRAG_HANDLE).doesNotExist(); }); test("you can add external links to a project", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { @@ -551,7 +732,7 @@ module("Acceptance | authenticated/projects/project", function (hooks) { assert.equal(projectLinks.length, 0); }); - test("external links can only be edited if the project is active", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + test("external links can only be edited / reordered if the project is active", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { this.server.schema.projects.first().update({ externalLinks: [this.server.create("related-external-link").attrs], }); @@ -560,18 +741,21 @@ module("Acceptance | authenticated/projects/project", function (hooks) { assert.dom(EXTERNAL_LINK).exists(); assert.dom(DOCUMENT_OVERFLOW_MENU_BUTTON).exists(); + assert.dom(LINK_DRAG_HANDLE).exists(); await click(STATUS_TOGGLE); await click(COMPLETED_STATUS_ACTION); assert.dom(EXTERNAL_LINK).exists(); assert.dom(DOCUMENT_OVERFLOW_MENU_BUTTON).doesNotExist(); + assert.dom(LINK_DRAG_HANDLE).doesNotExist(); await click(STATUS_TOGGLE); await click(ARCHIVED_STATUS_ACTION); assert.dom(EXTERNAL_LINK).exists(); assert.dom(DOCUMENT_OVERFLOW_MENU_BUTTON).doesNotExist(); + assert.dom(LINK_DRAG_HANDLE).doesNotExist(); }); test('the "add resource" button is hidden when the project is inactive', async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { diff --git a/web/tests/integration/components/doc/tile-medium-test.ts b/web/tests/integration/components/doc/tile-medium-test.ts index 7a9dbc64e..70bfca2da 100644 --- a/web/tests/integration/components/doc/tile-medium-test.ts +++ b/web/tests/integration/components/doc/tile-medium-test.ts @@ -22,10 +22,12 @@ const USER_NAME = "[data-test-document-owner-name]"; const STATUS = "[data-test-document-status]"; const TYPE = "[data-test-document-type]"; const THUMBNAIL_BADGE = "[data-test-doc-thumbnail-product-badge]"; +const PRIMARY_CLICK_AREA = "[data-test-primary-click-area]"; interface Context extends MirageTestContext { doc: HermesDocument | RelatedHermesDocument; query: string; + canBeReordered: boolean; } module("Integration | Component | doc/tile-medium", function (hooks) { @@ -174,4 +176,29 @@ module("Integration | Component | doc/tile-medium", function (hooks) { assert.dom(TITLE).hasText(title); assert.dom(`${TITLE} mark`).hasText(query); }); + + test("when `canBeReordered` is true, the click affordance changes", async function (this: Context, assert) { + this.set("doc", this.server.schema.document.first().attrs); + this.set("canBeReordered", false); + + await render(hbs` +
    + +
    + `); + + assert.dom(PRIMARY_CLICK_AREA).hasStyle({ left: "0px", width: "500px" }); + + this.set("canBeReordered", true); + + assert + .dom(PRIMARY_CLICK_AREA) + .hasStyle( + { left: "-20px", width: "520px", right: "0px" }, + 'when "canBeReordered" is true, the click area extends to the left by 20px to make room for the drag handle', + ); + }); }); diff --git a/web/tests/integration/components/project/resource-test.ts b/web/tests/integration/components/project/resource-test.ts new file mode 100644 index 000000000..98f4592b6 --- /dev/null +++ b/web/tests/integration/components/project/resource-test.ts @@ -0,0 +1,202 @@ +import { click, findAll, render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support"; +import { setupRenderingTest } from "ember-qunit"; +import { module, test } from "qunit"; +import { assert as emberAssert } from "@ember/debug"; +import { + MoveOptionIcon, + MoveOptionLabel, +} from "hermes/components/project/resource"; + +const DRAG_HANDLE = "[data-test-drag-handle]"; +const MOVE_OPTION = "[data-test-move-option]"; +const MOVE_ICON = "[data-test-move-icon]"; + +interface Context extends MirageTestContext { + overflowMenuItems: {}; + isReadOnly: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + moveToTop: () => void; + moveUp: () => void; + moveDown: () => void; + moveToBottom: () => void; +} + +module("Integration | Component | project/resource", function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function (this: Context) { + this.set("overflowMenuItems", {}); + this.set("canMoveUp", true); + this.set("canMoveDown", true); + this.set("moveToTop", () => {}); + this.set("moveUp", () => {}); + this.set("moveDown", () => {}); + this.set("moveToBottom", () => {}); + }); + + test("it shows drag controls unless read-only", async function (this: Context, assert) { + this.set("isReadOnly", true); + + await render(hbs` + + `); + + assert + .dom(DRAG_HANDLE) + .doesNotExist("drag handle is not shown when read-only"); + + this.set("isReadOnly", false); + + assert.dom(DRAG_HANDLE).exists("drag handle is shown when not read-only"); + }); + + test("it shows options based on `canMoveUp` and `canMoveDown`", async function (this: Context, assert) { + await render(hbs` + + `); + + await click(DRAG_HANDLE); + + assert.dom(MOVE_OPTION).exists({ count: 4 }, "all move options are shown"); + + const moveIcons = findAll(MOVE_ICON).map((el) => + el.getAttribute("data-test-icon"), + ); + + assert.deepEqual( + moveIcons, + [ + MoveOptionIcon.Top, + MoveOptionIcon.Up, + MoveOptionIcon.Down, + MoveOptionIcon.Bottom, + ], + "correct icons are shown", + ); + + let labels = findAll(MOVE_OPTION).map((el) => el.textContent?.trim()); + + assert.deepEqual( + labels, + [ + MoveOptionLabel.Top, + MoveOptionLabel.Up, + MoveOptionLabel.Down, + MoveOptionLabel.Bottom, + ], + "correct labels are shown when both `canMoveUp` and `canMoveDown` are true", + ); + + this.set("canMoveUp", false); + + labels = findAll(MOVE_OPTION).map((el) => el.textContent?.trim()); + + assert.deepEqual( + labels, + [MoveOptionLabel.Down, MoveOptionLabel.Bottom], + "only move-down options are shown when `canMoveUp` is false", + ); + + this.set("canMoveUp", true); + this.set("canMoveDown", false); + + labels = findAll(MOVE_OPTION).map((el) => el.textContent?.trim()); + + assert.deepEqual( + labels, + [MoveOptionLabel.Top, MoveOptionLabel.Up], + "only move-up options are shown when `canMoveDown` is false", + ); + }); + + test("it triggers the correct action when an option is clicked", async function (this: Context, assert) { + let [moveToTopCount, moveUpCount, moveDownCount, moveToBottomCount] = [ + 0, 0, 0, 0, + ]; + + this.set("moveToTop", () => moveToTopCount++); + this.set("moveUp", () => moveUpCount++); + this.set("moveDown", () => moveDownCount++); + this.set("moveToBottom", () => moveToBottomCount++); + + await render(hbs` + + `); + + await click(DRAG_HANDLE); + await click(MOVE_OPTION); + + assert.equal(moveToTopCount, 1); + assert.equal(moveUpCount, 0); + assert.equal(moveDownCount, 0); + assert.equal(moveToBottomCount, 0); + + await click(DRAG_HANDLE); + + const secondOption = findAll(MOVE_OPTION)[1]; + + emberAssert("second move option exists", secondOption); + + await click(secondOption); + + assert.equal(moveToTopCount, 1); + assert.equal(moveUpCount, 1); + assert.equal(moveDownCount, 0); + assert.equal(moveToBottomCount, 0); + + await click(DRAG_HANDLE); + + const thirdOption = findAll(MOVE_OPTION)[2]; + + emberAssert("third move option exists", thirdOption); + + await click(thirdOption); + + assert.equal(moveToTopCount, 1); + assert.equal(moveUpCount, 1); + assert.equal(moveDownCount, 1); + assert.equal(moveToBottomCount, 0); + + await click(DRAG_HANDLE); + + const fourthOption = findAll(MOVE_OPTION)[3]; + + emberAssert("fourth move option exists", fourthOption); + + await click(fourthOption); + + assert.equal(moveToTopCount, 1); + assert.equal(moveUpCount, 1); + assert.equal(moveDownCount, 1); + assert.equal(moveToBottomCount, 1); + }); +}); diff --git a/web/yarn.lock b/web/yarn.lock index c90304234..680b46949 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -188,6 +188,36 @@ __metadata: languageName: node linkType: hard +"@atlaskit/pragmatic-drag-and-drop-hitbox@npm:^1.0.3": + version: 1.0.3 + resolution: "@atlaskit/pragmatic-drag-and-drop-hitbox@npm:1.0.3" + dependencies: + "@atlaskit/pragmatic-drag-and-drop": ^1.1.0 + "@babel/runtime": ^7.0.0 + checksum: cd055b1b46a2b79d6c2d35c1212459dd646659402eaf35c3d260a48712a16c797ff3f0a5fb260bf380550b5c09ef966996615fe97dd4cad8c7e2393168e9dd54 + languageName: node + linkType: hard + +"@atlaskit/pragmatic-drag-and-drop-live-region@npm:^1.0.4": + version: 1.0.4 + resolution: "@atlaskit/pragmatic-drag-and-drop-live-region@npm:1.0.4" + dependencies: + "@babel/runtime": ^7.0.0 + checksum: 7057e5d07b1b1849e365af8e128e8a3fa9a8b281a449ffe386a2d1d588a067a1c208264d0554514d736ef3239159b8ca65f8da970584ccabca8eee0ef49f917e + languageName: node + linkType: hard + +"@atlaskit/pragmatic-drag-and-drop@npm:^1.1.0, @atlaskit/pragmatic-drag-and-drop@npm:^1.1.3": + version: 1.1.3 + resolution: "@atlaskit/pragmatic-drag-and-drop@npm:1.1.3" + dependencies: + "@babel/runtime": ^7.0.0 + bind-event-listener: ^2.1.1 + raf-schd: ^4.0.3 + checksum: fcf2b2060c5012f98dcefb41b27e0c94799f82fa27b9e422ed32bf6c8d9f3bc02588a8ae25f2bed3d308394f621e2dd934ddb0f8a9748e8d1cf5d859c676463e + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.18.6": version: 7.18.6 resolution: "@babel/code-frame@npm:7.18.6" @@ -3422,19 +3452,29 @@ __metadata: languageName: node linkType: hard -"@floating-ui/core@npm:^1.2.3": - version: 1.2.4 - resolution: "@floating-ui/core@npm:1.2.4" - checksum: 1c163ea1804e2b0a28fda6e32efed0e242d0db8081fd24aab9d1cbb100f94a558709231c483bf74bf09a9204ea6e7845813d43b5322ceb6ee63285308f68f65b +"@floating-ui/core@npm:^1.0.0": + version: 1.6.0 + resolution: "@floating-ui/core@npm:1.6.0" + dependencies: + "@floating-ui/utils": ^0.2.1 + checksum: 2e25c53b0c124c5c9577972f8ae21d081f2f7895e6695836a53074463e8c65b47722744d6d2b5a993164936da006a268bcfe87fe68fd24dc235b1cb86bed3127 languageName: node linkType: hard -"@floating-ui/dom@npm:^1.2.4": - version: 1.2.4 - resolution: "@floating-ui/dom@npm:1.2.4" +"@floating-ui/dom@npm:^1.6.3": + version: 1.6.3 + resolution: "@floating-ui/dom@npm:1.6.3" dependencies: - "@floating-ui/core": ^1.2.3 - checksum: 5c24a2e8f04e436390646c8a4431c6cb79e03711fbb0f818b87d613a6be8971bc560a830b702aaa51a0ebc4d0c45deb06f3140c14125dc1ac770365bf66ee903 + "@floating-ui/core": ^1.0.0 + "@floating-ui/utils": ^0.2.0 + checksum: 81cbb18ece3afc37992f436e469e7fabab2e433248e46fff4302d12493a175b0c64310f8a971e6e1eda7218df28ace6b70237b0f3c22fe12a21bba05b5579555 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.1": + version: 0.2.1 + resolution: "@floating-ui/utils@npm:0.2.1" + checksum: 9ed4380653c7c217cd6f66ae51f20fdce433730dbc77f95b5abfb5a808f5fdb029c6ae249b4e0490a816f2453aa6e586d9a873cd157fdba4690f65628efc6e06 languageName: node linkType: hard @@ -7024,6 +7064,13 @@ __metadata: languageName: node linkType: hard +"bind-event-listener@npm:^2.1.1": + version: 2.1.1 + resolution: "bind-event-listener@npm:2.1.1" + checksum: 685776bacac205611f9fff8b76b61f270a7292195a7d03400acb0f91d52a82775bd00cc508b5b9042eb6d3ac762c43f552bb55a785200de5564a9e0126d6f88e + languageName: node + linkType: hard + "bindings@npm:^1.5.0": version: 1.5.0 resolution: "bindings@npm:1.5.0" @@ -13462,10 +13509,13 @@ __metadata: version: 0.0.0-use.local resolution: "hermes@workspace:." dependencies: + "@atlaskit/pragmatic-drag-and-drop": ^1.1.3 + "@atlaskit/pragmatic-drag-and-drop-hitbox": ^1.0.3 + "@atlaskit/pragmatic-drag-and-drop-live-region": ^1.0.4 "@csstools/postcss-sass": ^5.0.1 "@ember/optional-features": ^2.0.0 "@ember/test-helpers": ^2.6.0 - "@floating-ui/dom": ^1.2.4 + "@floating-ui/dom": ^1.6.3 "@gavant/glint-template-types": ^0.3.6 "@glimmer/component": ^1.0.4 "@glimmer/tracking": ^1.0.4 @@ -17761,6 +17811,13 @@ __metadata: languageName: node linkType: hard +"raf-schd@npm:^4.0.3": + version: 4.0.3 + resolution: "raf-schd@npm:4.0.3" + checksum: 45514041c5ad31fa96aef3bb3c572a843b92da2f2cd1cb4a47c9ad58e48761d3a4126e18daa32b2bfa0bc2551a42d8f324a0e40e536cb656969929602b4e8b58 + languageName: node + linkType: hard + "randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0"