mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Replace move spinner with progress bar
The full-screen `withLoadingIndicator` overlay was heavy feedback for a small reorder. Moves now drive the Turbo progress bar through a new `withProgressBar` helper, mark the affected lists with `aria-busy`, and dim only the inner `.Box-list`. The fade honours `prefers-reduced-motion`; the root keeps `data-sortable-lists-moving` as the cross-controller drag guard.
This commit is contained in:
@@ -49,6 +49,15 @@
|
|||||||
container-name: backlogsListsContainer
|
container-name: backlogsListsContainer
|
||||||
container-type: inline-size
|
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
|
.op-sprint-planning-container
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: row
|
flex-direction: row
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('Sortable lists controller', () => {
|
|||||||
let ctx:StimulusTestContext;
|
let ctx:StimulusTestContext;
|
||||||
let fixture:HTMLElement;
|
let fixture:HTMLElement;
|
||||||
let fetchMock:ReturnType<typeof vi.fn>;
|
let fetchMock:ReturnType<typeof vi.fn>;
|
||||||
let loadingIndicator:HTMLElement;
|
let turboHelpers:typeof import('core-turbo/helpers');
|
||||||
let renderStreamMessageMock:ReturnType<typeof vi.fn>;
|
let renderStreamMessageMock:ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -69,6 +69,8 @@ describe('Sortable lists controller', () => {
|
|||||||
({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'));
|
({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'));
|
||||||
({ default: SortableListsController } = await import('./sortable-lists.controller'));
|
({ default: SortableListsController } = await import('./sortable-lists.controller'));
|
||||||
({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop'));
|
({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop'));
|
||||||
|
|
||||||
|
turboHelpers = await import('core-turbo/helpers');
|
||||||
});
|
});
|
||||||
|
|
||||||
function input() {
|
function input() {
|
||||||
@@ -220,10 +222,8 @@ describe('Sortable lists controller', () => {
|
|||||||
renderStreamMessage: renderStreamMessageMock,
|
renderStreamMessage: renderStreamMessageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
loadingIndicator = document.createElement('div');
|
vi.spyOn(turboHelpers.TurboHelpers, 'showProgressBar').mockImplementation(() => undefined);
|
||||||
loadingIndicator.id = 'global-loading-indicator';
|
vi.spyOn(turboHelpers.TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined);
|
||||||
loadingIndicator.hidden = true;
|
|
||||||
document.body.appendChild(loadingIndicator);
|
|
||||||
|
|
||||||
ctx = await setupStimulusTest({
|
ctx = await setupStimulusTest({
|
||||||
controllers: {
|
controllers: {
|
||||||
@@ -235,7 +235,7 @@ describe('Sortable lists controller', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
ctx.dispose();
|
ctx.dispose();
|
||||||
loadingIndicator.remove();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ describe('Sortable lists controller', () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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;
|
let resolveMove:(response:Response) => void;
|
||||||
|
|
||||||
fetchMock.mockImplementationOnce(() => {
|
fetchMock.mockImplementationOnce(() => {
|
||||||
@@ -352,15 +352,15 @@ describe('Sortable lists controller', () => {
|
|||||||
await dropCurrentItemOnList(firstSourceItem, targetList);
|
await dropCurrentItemOnList(firstSourceItem, targetList);
|
||||||
|
|
||||||
expect(root.dataset.sortableListsMoving).toEqual('true');
|
expect(root.dataset.sortableListsMoving).toEqual('true');
|
||||||
expect(root.getAttribute('aria-busy')).toEqual('true');
|
expect(targetList.getAttribute('aria-busy')).toEqual('true');
|
||||||
expect(loadingIndicator.hidden).toBe(false);
|
expect(turboHelpers.TurboHelpers.showProgressBar).toHaveBeenCalled();
|
||||||
|
|
||||||
resolveMove!(new Response('', { status: 200 }));
|
resolveMove!(new Response('', { status: 200 }));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false);
|
expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false);
|
||||||
expect(root.hasAttribute('aria-busy')).toBe(false);
|
expect(targetList.hasAttribute('aria-busy')).toBe(false);
|
||||||
expect(loadingIndicator.hidden).toBe(true);
|
expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects new sortable-list drags and drops while a move is pending', async () => {
|
it('rejects new sortable-list drags and drops while a move is pending', async () => {
|
||||||
@@ -410,7 +410,7 @@ describe('Sortable lists controller', () => {
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
}));
|
}));
|
||||||
expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false);
|
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);
|
window.removeEventListener('op:toasters:add', onToast);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { Controller } from '@hotwired/stimulus';
|
|||||||
import { FetchRequest } from '@rails/request.js';
|
import { FetchRequest } from '@rails/request.js';
|
||||||
import { debugLog } from 'core-app/shared/helpers/debug_output';
|
import { debugLog } from 'core-app/shared/helpers/debug_output';
|
||||||
import { flipMove } from 'core-stimulus/helpers/flip-helper';
|
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 { parseTemplate } from 'url-template';
|
||||||
import {
|
import {
|
||||||
acceptsSortableItemType,
|
acceptsSortableItemType,
|
||||||
@@ -253,7 +253,7 @@ export default class SortableListsController extends Controller<HTMLElement> {
|
|||||||
|
|
||||||
this.setMoving(true);
|
this.setMoving(true);
|
||||||
try {
|
try {
|
||||||
const response = await withLoadingIndicator(request.perform());
|
const response = await withProgressBar(request.perform());
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
debugLog(`Failed to move sortable list item: ${response.statusCode}`);
|
debugLog(`Failed to move sortable list item: ${response.statusCode}`);
|
||||||
@@ -273,10 +273,10 @@ export default class SortableListsController extends Controller<HTMLElement> {
|
|||||||
private setMoving(moving:boolean):void {
|
private setMoving(moving:boolean):void {
|
||||||
if (moving) {
|
if (moving) {
|
||||||
this.element.setAttribute(sortableListsMovingAttribute, 'true');
|
this.element.setAttribute(sortableListsMovingAttribute, 'true');
|
||||||
this.element.setAttribute('aria-busy', 'true');
|
this.listTargets.forEach((list) => list.setAttribute('aria-busy', 'true'));
|
||||||
} else {
|
} else {
|
||||||
this.element.removeAttribute(sortableListsMovingAttribute);
|
this.element.removeAttribute(sortableListsMovingAttribute);
|
||||||
this.element.removeAttribute('aria-busy');
|
this.listTargets.forEach((list) => list.removeAttribute('aria-busy'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,9 @@
|
|||||||
//++
|
//++
|
||||||
|
|
||||||
import type { FetchResponse } from '@rails/request.js';
|
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', () => {
|
describe('withLoadingIndicator', () => {
|
||||||
let indicator:HTMLElement|null;
|
let indicator:HTMLElement|null;
|
||||||
@@ -75,3 +76,37 @@ describe('withLoadingIndicator', () => {
|
|||||||
expect(() => withLoadingIndicator(Promise.resolve({} as FetchResponse))).toThrow();
|
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<FetchResponse>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
import { FetchRequest, FetchResponse, Options } from '@rails/request.js';
|
import { FetchRequest, FetchResponse, Options } from '@rails/request.js';
|
||||||
import { hideElement, showElement } from 'core-app/shared/helpers/dom-helpers';
|
import { hideElement, showElement } from 'core-app/shared/helpers/dom-helpers';
|
||||||
|
import { TurboHelpers } from 'core-turbo/helpers';
|
||||||
import invariant from 'tiny-invariant';
|
import invariant from 'tiny-invariant';
|
||||||
|
|
||||||
export function post(url:string|URL, options?:Options) {
|
export function post(url:string|URL, options?:Options) {
|
||||||
@@ -47,6 +48,14 @@ export function withLoadingIndicator(request:Promise<FetchResponse>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withProgressBar(request:Promise<FetchResponse>) {
|
||||||
|
TurboHelpers.showProgressBar();
|
||||||
|
|
||||||
|
return request.finally(() => {
|
||||||
|
TurboHelpers.hideProgressBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class FetchRequestError extends Error {
|
export class FetchRequestError extends Error {
|
||||||
constructor(public _errorCode:number, message = 'HTTP Error', options:ErrorOptions = {}) {
|
constructor(public _errorCode:number, message = 'HTTP Error', options:ErrorOptions = {}) {
|
||||||
super(message, options);
|
super(message, options);
|
||||||
|
|||||||
Reference in New Issue
Block a user