From 31ccda4501fff2627ae38d45496521dab61a9b67 Mon Sep 17 00:00:00 2001 From: ayaan-qadri Date: Wed, 13 Nov 2024 17:29:50 +0530 Subject: [PATCH] Create new Stimulus ZoneController (w-zone) --- .../src/controllers/ZoneController.stories.js | 46 +++++ client/src/controllers/ZoneController.test.js | 169 ++++++++++++++++++ client/src/controllers/ZoneController.ts | 76 ++++++++ client/src/controllers/index.ts | 2 + 4 files changed, 293 insertions(+) create mode 100644 client/src/controllers/ZoneController.stories.js create mode 100644 client/src/controllers/ZoneController.test.js create mode 100644 client/src/controllers/ZoneController.ts diff --git a/client/src/controllers/ZoneController.stories.js b/client/src/controllers/ZoneController.stories.js new file mode 100644 index 0000000000..d1a33aa8bd --- /dev/null +++ b/client/src/controllers/ZoneController.stories.js @@ -0,0 +1,46 @@ +import React from 'react'; + +import { StimulusWrapper } from '../../storybook/StimulusWrapper'; +import { ZoneController } from './ZoneController'; + +export default { + title: 'Stimulus / ZoneController', + argTypes: { + debug: { + control: 'boolean', + defaultValue: false, + }, + }, +}; + +const definitions = [ + { + identifier: 'w-zone', + controllerConstructor: ZoneController, + }, +]; + +const Template = ({ debug = false }) => ( + +
+ Drag something here +
+ +

+ Drag an item over the box, and drop it to see class activation and + deactivation in action. +

+
+); + +export const Base = Template.bind({}); diff --git a/client/src/controllers/ZoneController.test.js b/client/src/controllers/ZoneController.test.js new file mode 100644 index 0000000000..238798c0af --- /dev/null +++ b/client/src/controllers/ZoneController.test.js @@ -0,0 +1,169 @@ +import { Application } from '@hotwired/stimulus'; +import { ZoneController } from './ZoneController'; + +jest.useFakeTimers(); + +describe('ZoneController', () => { + let application; + + const setup = async (html) => { + document.body.innerHTML = `
${html}
`; + + application = Application.start(); + application.register('w-zone', ZoneController); + + await Promise.resolve(); + }; + + afterEach(() => { + application?.stop(); + jest.clearAllMocks(); + }); + + describe('activate method', () => { + it('should add active class to the element', async () => { + await setup(` +
+ `); + + const element = document.querySelector('.drop-zone'); + element.dispatchEvent(new Event('dragover')); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(true); + }); + }); + + describe('deactivate method', () => { + it('should remove active class from the element', async () => { + await setup(` +
+ `); + + const element = document.querySelector('.drop-zone'); + element.dispatchEvent(new Event('dragleave')); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(false); + }); + + it('should not throw an error if active class is not present', async () => { + await setup(` +
+ `); + + const element = document.querySelector('.drop-zone'); + expect(() => element.dispatchEvent(new Event('dragleave'))).not.toThrow(); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(false); + }); + }); + + describe('noop method', () => { + it('should allow for arbitrary stimulus actions via the noop method', async () => { + await setup(` +
+ `); + + const element = document.querySelector('.drop-zone'); + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }); + element.dispatchEvent(dropEvent); + await jest.runAllTimersAsync(); + expect(dropEvent.defaultPrevented).toBe(true); + }); + }); + + describe('delay value', () => { + it('should delay the mode change by the provided value', async () => { + await setup(` +
+ `); + + const element = document.querySelector('.drop-zone'); + + element.dispatchEvent(new Event('dragover')); + await Promise.resolve(jest.advanceTimersByTime(50)); + + expect(element.classList.contains('active')).toBe(false); + + await jest.advanceTimersByTime(55); + expect(element.classList.contains('active')).toBe(true); + + // deactivate should take twice as long (100 x 2 = 200ms) + + element.dispatchEvent(new Event('dragleave')); + + await Promise.resolve(jest.advanceTimersByTime(180)); + + expect(element.classList.contains('active')).toBe(true); + + await Promise.resolve(jest.advanceTimersByTime(20)); + expect(element.classList.contains('active')).toBe(false); + }); + }); + + describe('example usage for drag & drop', () => { + it('should handle multiple drag-related events correctly', async () => { + await setup(` +
+ `); + + const element = document.querySelector('.drop-zone'); + + // Simulate dragover + const dragoverEvent = new Event('dragover', { + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(dragoverEvent); + expect(dragoverEvent.defaultPrevented).toBe(true); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(true); + + // Simulate dragleave + element.dispatchEvent(new Event('dragleave')); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(false); + + // Simulate dragover again for dragend + element.dispatchEvent(dragoverEvent); + expect(dragoverEvent.defaultPrevented).toBe(true); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(true); + + // Simulate dragend + element.dispatchEvent(new Event('dragend')); + await jest.runAllTimersAsync(); + expect(element.classList.contains('hovered')).toBe(false); + }); + }); +}); diff --git a/client/src/controllers/ZoneController.ts b/client/src/controllers/ZoneController.ts new file mode 100644 index 0000000000..54d890fe3d --- /dev/null +++ b/client/src/controllers/ZoneController.ts @@ -0,0 +1,76 @@ +import { Controller } from '@hotwired/stimulus'; +import { debounce } from '../utils/debounce'; + +enum ZoneMode { + Active = 'active', + Inactive = '', +} + +/** + * Enables the controlled element to respond to specific user interactions + * by adding or removing CSS classes dynamically. + * + * @example + * ```html + *
+ * Drag files here and see the effect. + *
+ * ``` + */ +export class ZoneController extends Controller { + static classes = ['active']; + + static values = { + delay: { type: Number, default: 0 }, + mode: { type: String, default: ZoneMode.Inactive }, + }; + + /** Tracks the current mode for this zone. */ + declare modeValue: ZoneMode; + + /** Classes to append when the mode is active & remove when inactive. */ + declare readonly activeClasses: string[]; + /** Delay, in milliseconds, to use when debouncing the mode updates. */ + declare readonly delayValue: number; + + initialize() { + const delayValue = this.delayValue; + if (delayValue <= 0) return; + this.activate = debounce(this.activate.bind(this), delayValue); + // Double the delay for deactivation to prevent flickering. + this.deactivate = debounce(this.deactivate.bind(this), delayValue * 2); + } + + activate() { + this.modeValue = ZoneMode.Active; + } + + deactivate() { + this.modeValue = ZoneMode.Inactive; + } + + modeValueChanged(current: ZoneMode) { + const activeClasses = this.activeClasses; + + if (!activeClasses.length) return; + + if (current === ZoneMode.Active) { + this.element.classList.add(...activeClasses); + } else { + this.element.classList.remove(...activeClasses); + } + } + + /** + * Intentionally does nothing. + * + * Useful for attaching data-action to leverage the built in + * Stimulus options without needing any extra functionality. + * e.g. preventDefault (`:prevent`) and stopPropagation (`:stop`). + */ + noop() {} +} diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index 0f6b0fdac9..40449cc696 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -30,6 +30,7 @@ import { TeleportController } from './TeleportController'; import { TooltipController } from './TooltipController'; import { UnsavedController } from './UnsavedController'; import { UpgradeController } from './UpgradeController'; +import { ZoneController } from './ZoneController'; /** * Important: Only add default core controllers that should load with the base admin JS bundle. @@ -67,4 +68,5 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: TooltipController, identifier: 'w-tooltip' }, { controllerConstructor: UnsavedController, identifier: 'w-unsaved' }, { controllerConstructor: UpgradeController, identifier: 'w-upgrade' }, + { controllerConstructor: ZoneController, identifier: 'w-zone' }, ];