diff --git a/frontend/src/global_styles/content/modules/_backlogs.sass b/frontend/src/global_styles/content/modules/_backlogs.sass index 6bcf06788d6..b9944613e77 100644 --- a/frontend/src/global_styles/content/modules/_backlogs.sass +++ b/frontend/src/global_styles/content/modules/_backlogs.sass @@ -49,6 +49,15 @@ container-name: backlogsListsContainer container-type: inline-size + [data-sortable-lists-target~="list"][aria-busy="true"] + cursor: progress + + .Box-list + opacity: 0.7 + + @media (prefers-reduced-motion: no-preference) + transition: opacity 0.1s ease-in-out + .op-sprint-planning-container display: flex flex-direction: row diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 76f20ecc859..327a88ee387 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -51,7 +51,7 @@ describe('Sortable lists controller', () => { let ctx:StimulusTestContext; let fixture:HTMLElement; let fetchMock:ReturnType; - let loadingIndicator:HTMLElement; + let turboHelpers:typeof import('core-turbo/helpers'); let renderStreamMessageMock:ReturnType; beforeAll(async () => { @@ -69,6 +69,8 @@ describe('Sortable lists controller', () => { ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); + + turboHelpers = await import('core-turbo/helpers'); }); function input() { @@ -220,10 +222,8 @@ describe('Sortable lists controller', () => { renderStreamMessage: renderStreamMessageMock, }); - loadingIndicator = document.createElement('div'); - loadingIndicator.id = 'global-loading-indicator'; - loadingIndicator.hidden = true; - document.body.appendChild(loadingIndicator); + vi.spyOn(turboHelpers.TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); + vi.spyOn(turboHelpers.TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); ctx = await setupStimulusTest({ controllers: { @@ -235,7 +235,7 @@ describe('Sortable lists controller', () => { afterEach(() => { ctx.dispose(); - loadingIndicator.remove(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -337,7 +337,7 @@ describe('Sortable lists controller', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('marks the sortable lists root and global loading indicator while moving an item', async () => { + it('marks the moving state and busy lists and shows the progress bar while moving an item', async () => { let resolveMove:(response:Response) => void; fetchMock.mockImplementationOnce(() => { @@ -352,15 +352,15 @@ describe('Sortable lists controller', () => { await dropCurrentItemOnList(firstSourceItem, targetList); expect(root.dataset.sortableListsMoving).toEqual('true'); - expect(root.getAttribute('aria-busy')).toEqual('true'); - expect(loadingIndicator.hidden).toBe(false); + expect(targetList.getAttribute('aria-busy')).toEqual('true'); + expect(turboHelpers.TurboHelpers.showProgressBar).toHaveBeenCalled(); resolveMove!(new Response('', { status: 200 })); await flushPromises(); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); - expect(root.hasAttribute('aria-busy')).toBe(false); - expect(loadingIndicator.hidden).toBe(true); + expect(targetList.hasAttribute('aria-busy')).toBe(false); + expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled(); }); it('rejects new sortable-list drags and drops while a move is pending', async () => { @@ -410,7 +410,7 @@ describe('Sortable lists controller', () => { type: 'error', })); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); - expect(loadingIndicator.hidden).toBe(true); + expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled(); window.removeEventListener('op:toasters:add', onToast); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 3280eb48789..b6580364aaa 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -36,7 +36,7 @@ import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; -import { withLoadingIndicator } from 'core-stimulus/helpers/request-helpers'; +import { withProgressBar } from 'core-stimulus/helpers/request-helpers'; import { parseTemplate } from 'url-template'; import { acceptsSortableItemType, @@ -253,7 +253,7 @@ export default class SortableListsController extends Controller { this.setMoving(true); try { - const response = await withLoadingIndicator(request.perform()); + const response = await withProgressBar(request.perform()); if (!response.ok) { debugLog(`Failed to move sortable list item: ${response.statusCode}`); @@ -273,10 +273,10 @@ export default class SortableListsController extends Controller { private setMoving(moving:boolean):void { if (moving) { this.element.setAttribute(sortableListsMovingAttribute, 'true'); - this.element.setAttribute('aria-busy', 'true'); + this.listTargets.forEach((list) => list.setAttribute('aria-busy', 'true')); } else { this.element.removeAttribute(sortableListsMovingAttribute); - this.element.removeAttribute('aria-busy'); + this.listTargets.forEach((list) => list.removeAttribute('aria-busy')); } } diff --git a/frontend/src/stimulus/helpers/request-helpers.spec.ts b/frontend/src/stimulus/helpers/request-helpers.spec.ts index 8a39adb5d30..ce3194bb6c9 100644 --- a/frontend/src/stimulus/helpers/request-helpers.spec.ts +++ b/frontend/src/stimulus/helpers/request-helpers.spec.ts @@ -27,8 +27,9 @@ //++ import type { FetchResponse } from '@rails/request.js'; +import { TurboHelpers } from 'core-turbo/helpers'; -import { withLoadingIndicator } from './request-helpers'; +import { withLoadingIndicator, withProgressBar } from './request-helpers'; describe('withLoadingIndicator', () => { let indicator:HTMLElement|null; @@ -75,3 +76,37 @@ describe('withLoadingIndicator', () => { expect(() => withLoadingIndicator(Promise.resolve({} as FetchResponse))).toThrow(); }); }); + +describe('withProgressBar', () => { + beforeEach(() => { + vi.spyOn(TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); + vi.spyOn(TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows the progress bar while the request is pending and hides it on success', async () => { + let resolveRequest!:(response:FetchResponse) => void; + const request = new Promise((resolve) => { resolveRequest = resolve; }); + + const wrapped = withProgressBar(request); + expect(TurboHelpers.showProgressBar).toHaveBeenCalledOnce(); + expect(TurboHelpers.hideProgressBar).not.toHaveBeenCalled(); + + resolveRequest({} as FetchResponse); + await wrapped; + + expect(TurboHelpers.hideProgressBar).toHaveBeenCalledOnce(); + }); + + it('hides the progress bar when the request rejects and propagates the rejection', async () => { + const error = new Error('boom'); + + const wrapped = withProgressBar(Promise.reject(error)); + + await expect(wrapped).rejects.toBe(error); + expect(TurboHelpers.hideProgressBar).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/stimulus/helpers/request-helpers.ts b/frontend/src/stimulus/helpers/request-helpers.ts index 0e4f3f76dac..ffe9f71b906 100644 --- a/frontend/src/stimulus/helpers/request-helpers.ts +++ b/frontend/src/stimulus/helpers/request-helpers.ts @@ -30,6 +30,7 @@ import { FetchRequest, FetchResponse, Options } from '@rails/request.js'; import { hideElement, showElement } from 'core-app/shared/helpers/dom-helpers'; +import { TurboHelpers } from 'core-turbo/helpers'; import invariant from 'tiny-invariant'; export function post(url:string|URL, options?:Options) { @@ -47,6 +48,14 @@ export function withLoadingIndicator(request:Promise) { }); } +export function withProgressBar(request:Promise) { + TurboHelpers.showProgressBar(); + + return request.finally(() => { + TurboHelpers.hideProgressBar(); + }); +} + export class FetchRequestError extends Error { constructor(public _errorCode:number, message = 'HTTP Error', options:ErrorOptions = {}) { super(message, options);