first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import fetchMock from 'fetch-mock'
import { expect } from 'chai'
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import useAbortController from '../../../../frontend/js/shared/hooks/use-abort-controller'
import { getJSON } from '../../../../frontend/js/infrastructure/fetch-json'
describe('useAbortController', function () {
let status: {
loading: boolean
success: boolean | null
error: any | null
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
status = {
loading: false,
success: null,
error: null,
}
})
after(function () {
fetchMock.removeRoutes().clearHistory()
})
function AbortableRequest({ url }: { url: string }) {
const { signal } = useAbortController()
React.useEffect(() => {
status.loading = true
getJSON(url, { signal })
.then(() => {
status.success = true
})
.catch(error => {
status.error = error
})
.finally(() => {
status.loading = false
})
}, [signal, url])
return null
}
it('calls then when the request succeeds', async function () {
fetchMock.get('/test', { status: 204 }, { delay: 100 })
render(<AbortableRequest url="/test" />)
expect(status.loading).to.be.true
await waitFor(() => expect(status.loading).to.be.false)
expect(status.success).to.be.true
expect(status.error).to.be.null
})
it('calls catch when the request fails', async function () {
fetchMock.get('/test', { status: 500 }, { delay: 100 })
render(<AbortableRequest url="/test" />)
expect(status.loading).to.be.true
await waitFor(() => expect(status.loading).to.be.false)
expect(status.success).to.be.null
expect(status.error).not.to.be.null
})
it('cancels a request when unmounted', async function () {
fetchMock.get('/test', { status: 204 }, { delay: 100 })
const { unmount } = render(<AbortableRequest url="/test" />)
expect(status.loading).to.be.true
unmount()
await fetchMock.callHistory.flush(true)
expect(fetchMock.callHistory.done()).to.be.true
// wait for Promises to be resolved
await new Promise(resolve => setTimeout(resolve, 0))
expect(status.success).to.be.null
expect(status.error).to.be.null
expect(status.loading).to.be.true
})
})

View File

@@ -0,0 +1,182 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { expect } from 'chai'
import sinon from 'sinon'
import useAsync from '../../../../frontend/js/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
function deferred() {
let res!: (
value: Record<string, unknown> | PromiseLike<Record<string, unknown>>
) => void
let rej!: (reason?: any) => void
const promise = new Promise<Record<string, unknown>>((resolve, reject) => {
res = resolve
rej = reject
})
return { promise, resolve: res, reject: rej }
}
const defaultState = {
status: 'idle',
data: null,
error: null,
isIdle: true,
isLoading: false,
isError: false,
isSuccess: false,
}
const pendingState = {
...defaultState,
status: 'pending',
isIdle: false,
isLoading: true,
}
const resolvedState = {
...defaultState,
status: 'resolved',
isIdle: false,
isSuccess: true,
}
const rejectedState = {
...defaultState,
status: 'rejected',
isIdle: false,
isError: true,
}
describe('useAsync', function () {
let spyOnDebugConsoleError: sinon.SinonSpy
beforeEach(function () {
spyOnDebugConsoleError = sinon.spy(debugConsole, 'error')
})
afterEach(function () {
spyOnDebugConsoleError.restore()
})
it('exposes the methods', function () {
const { result } = renderHook(() => useAsync())
expect(result.current.setData).to.be.a('function')
expect(result.current.setError).to.be.a('function')
expect(result.current.runAsync).to.be.a('function')
})
it('calling `runAsync` with a promise which resolves', async function () {
const { promise, resolve } = deferred()
const { result } = renderHook(() => useAsync())
expect(result.current).to.include(defaultState)
let p: Promise<unknown>
act(() => {
p = result.current.runAsync(promise)
})
expect(result.current).to.include(pendingState)
const resolvedValue = {}
await act(async () => {
resolve(resolvedValue)
await p
})
expect(result.current).to.include({
...resolvedState,
data: resolvedValue,
})
act(() => {
result.current.reset()
})
expect(result.current).to.include(defaultState)
})
it('calling `runAsync` with a promise which rejects', async function () {
const { promise, reject } = deferred()
const { result } = renderHook(() => useAsync())
expect(result.current).to.include(defaultState)
let p: Promise<unknown>
act(() => {
p = result.current.runAsync(promise).catch(() => {})
})
expect(result.current).to.include(pendingState)
const rejectedValue = Symbol('rejected value')
await act(async () => {
reject(rejectedValue)
await p
})
expect(result.current).to.include({
...rejectedState,
error: rejectedValue,
})
})
it('can specify an initial state', function () {
const mockData = Symbol('resolved value')
const customInitialState = { status: 'resolved' as const, data: mockData }
const { result } = renderHook(() => useAsync(customInitialState))
expect(result.current).to.include({
...resolvedState,
...customInitialState,
})
})
it('can set the data', function () {
const mockData = Symbol('resolved value')
const { result } = renderHook(() => useAsync())
act(() => {
result.current.setData(mockData)
})
expect(result.current).to.include({
...resolvedState,
data: mockData,
})
})
it('can set the error', function () {
const mockError = new Error('rejected value')
const { result } = renderHook(() => useAsync())
act(() => {
result.current.setError(mockError)
})
expect(result.current).to.include({
...rejectedState,
error: mockError,
})
})
it('no state updates happen if the component is unmounted while pending', async function () {
const { promise, resolve } = deferred()
const { result, unmount } = renderHook(() => useAsync())
let p: Promise<unknown>
act(() => {
p = result.current.runAsync(promise)
})
unmount()
await act(async () => {
resolve({})
await p
})
expect(debugConsole.error).not.to.have.been.called
})
})

View File

@@ -0,0 +1,34 @@
import sinon from 'sinon'
import { renderHook } from '@testing-library/react-hooks'
import useCallbackHandlers from '../../../../frontend/js/shared/hooks/use-callback-handlers'
describe('useCallbackHandlers', function () {
it('adds, removes and calls all handlers without duplicate', async function () {
const handler1 = sinon.stub()
const handler2 = sinon.stub()
const handler3 = sinon.stub()
const { result } = renderHook(() => useCallbackHandlers())
result.current.addHandler(handler1)
result.current.deleteHandler(handler1)
result.current.addHandler(handler1)
result.current.addHandler(handler2)
result.current.deleteHandler(handler2)
result.current.addHandler(handler3)
result.current.addHandler(handler3)
result.current.callHandlers('foo')
result.current.callHandlers(1337)
sinon.assert.calledTwice(handler1)
sinon.assert.calledWith(handler1, 'foo')
sinon.assert.calledWith(handler1, 1337)
sinon.assert.notCalled(handler2)
sinon.assert.calledTwice(handler3)
})
})

View File

@@ -0,0 +1,113 @@
import { FC } from 'react'
import useDetachAction from '../../../../frontend/js/shared/hooks/use-detach-action'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { EditorProviders } from '../../helpers/editor-providers'
const DetachActionTest: FC<{
actionName: string
actionFunction: () => void
handleClick: (trigger: (value: any) => void) => void
}> = ({ actionName, actionFunction, handleClick }) => {
const trigger = useDetachAction(
actionName,
actionFunction,
'detacher',
'detached'
)
return (
<button id="trigger" onClick={() => handleClick(trigger)}>
trigger
</button>
)
}
describe('useDetachAction', function () {
it('broadcast message as sender', function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders>
<DetachActionTest
actionName="some-action"
actionFunction={cy.stub().as('actionFunction')}
handleClick={trigger => trigger('foo')}
/>
</EditorProviders>
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.get('#trigger').click()
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detacher',
event: 'action-some-action',
data: { args: ['foo'] },
})
cy.get('@actionFunction').should('not.have.been.called')
})
it('call function as non-sender', function () {
cy.mount(
<EditorProviders>
<DetachActionTest
actionName="some-action"
actionFunction={cy.stub().as('actionFunction')}
handleClick={trigger => trigger('foo')}
/>
</EditorProviders>
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.get('#trigger').click()
cy.get('@postDetachMessage').should('not.have.been.called')
cy.get('@actionFunction').should('have.been.calledWith', 'foo')
})
it('receive message and call function as target', function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
cy.mount(
<EditorProviders>
<DetachActionTest
actionName="some-action"
actionFunction={cy.stub().as('actionFunction')}
handleClick={trigger => trigger('foo')}
/>
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'action-some-action',
data: { args: ['foo'] },
})
})
cy.get('@actionFunction').should('have.been.calledWith', 'foo')
})
it('receive message and does not call function as non-target', function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders>
<DetachActionTest
actionName="some-action"
actionFunction={cy.stub().as('actionFunction')}
handleClick={trigger => trigger('foo')}
/>
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'action-some-action',
data: { args: [] },
})
})
cy.get('@actionFunction').should('not.have.been.called')
})
})

View File

@@ -0,0 +1,249 @@
import useDetachLayout from '../../../../frontend/js/shared/hooks/use-detach-layout'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { EditorProviders } from '../../helpers/editor-providers'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
const DetachLayoutTest = () => {
const { role, reattach, detach, isLinked, isLinking, isRedundant } =
useDetachLayout()
return (
<fieldset>
<legend>
role: <span id="role">{role || 'none'}</span>
</legend>
<OLFormGroup>
<OLFormCheckbox id="isLinked" inline checked={isLinked} readOnly />
<OLFormLabel>linked</OLFormLabel>
</OLFormGroup>
<OLFormGroup>
<OLFormCheckbox id="isLinking" inline checked={isLinking} readOnly />
<OLFormLabel>linking</OLFormLabel>
</OLFormGroup>
<OLFormGroup>
<OLFormCheckbox
id="isRedundant"
inline
checked={isRedundant}
readOnly
/>
<OLFormLabel>redundant</OLFormLabel>
</OLFormGroup>
<OLButton id="reattach" onClick={reattach}>
reattach
</OLButton>
<OLButton id="detach" onClick={detach}>
detach
</OLButton>
</fieldset>
)
}
describe('useDetachLayout', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.stub(window, 'open').as('openWindow')
cy.stub(window, 'close').as('closeWindow')
cy.interceptEvents()
})
it('detaching', function () {
// 1. create hook in normal mode
cy.mount(
<EditorProviders>
<DetachLayoutTest />
</EditorProviders>
)
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'none')
// 2. detach
cy.get('#detach').click()
cy.get('@openWindow').should(
'have.been.calledOnceWith',
Cypress.sinon.match(/\/detached/),
'_blank'
)
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('be.checked')
cy.get('#role').should('have.text', 'detacher')
})
it('detacher role', function () {
// 1. create hook in detacher mode
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders>
<DetachLayoutTest />
</EditorProviders>
)
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detacher')
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'connected',
})
})
// 2. simulate connected detached tab
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detacher',
event: 'up',
})
cy.get('#isLinked').should('be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detacher')
// 3. simulate closed detached tab
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'closed',
})
})
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detacher')
// 4. simulate up detached tab
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'up',
})
})
cy.get('#isLinked').should('be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detacher')
// 5. reattach
cy.get('@postDetachMessage').invoke('resetHistory')
cy.get('#reattach').click()
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'none')
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detacher',
event: 'reattach',
})
})
it('reset detacher role when other detacher tab connects', function () {
// 1. create hook in detacher mode
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders>
<DetachLayoutTest />
</EditorProviders>
)
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detacher')
// 2. simulate other detacher tab
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'up',
})
})
cy.get('#isRedundant').should('be.checked')
cy.get('#role').should('have.text', 'none')
})
it('detached role', function () {
// 1. create hook in detached mode
window.metaAttributesCache.set('ol-detachRole', 'detached')
cy.mount(
<EditorProviders>
<DetachLayoutTest />
</EditorProviders>
)
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detached')
// 2. simulate up detacher tab
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'up',
})
})
cy.get('#isLinked').should('be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detached')
// 3. simulate closed detacher tab
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'closed',
})
})
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detached')
// 4. simulate up detacher tab
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'up',
})
})
cy.get('#isLinked').should('be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detached')
// 5. simulate closed detached tab
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'closed',
})
})
cy.get('#isLinked').should('be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detached')
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'up',
})
// 6. simulate reattach event
cy.get('@postDetachMessage').invoke('resetHistory')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'reattach',
})
})
cy.get('#isLinked').should('not.be.checked')
cy.get('#isLinking').should('not.be.checked')
cy.get('#role').should('have.text', 'detached')
cy.get('@closeWindow').should('have.been.called')
})
})

View File

@@ -0,0 +1,100 @@
import { FC } from 'react'
import useDetachState from '../../../../frontend/js/shared/hooks/use-detach-state'
import { EditorProviders } from '../../helpers/editor-providers'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
const DetachStateTest: FC<{
stateKey: string
defaultValue: any
senderRole?: string
targetRole?: string
handleClick: (setValue: (value: any) => void) => void
}> = ({ stateKey, defaultValue, handleClick, senderRole, targetRole }) => {
const [value, setValue] = useDetachState(
stateKey,
defaultValue,
senderRole,
targetRole
)
return (
<div>
<div id="value">{value}</div>
<button id="setValue" onClick={() => handleClick(setValue)}>
set value
</button>
</div>
)
}
describe('useDetachState', function () {
it('create and update state', function () {
cy.mount(
<EditorProviders>
<DetachStateTest
stateKey="some-key"
defaultValue="foobar"
handleClick={setValue => {
setValue('barbaz')
}}
/>
</EditorProviders>
)
cy.get('#value').should('have.text', 'foobar')
cy.get('#setValue').click()
cy.get('#value').should('have.text', 'barbaz')
})
it('broadcast message as sender', function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders>
<DetachStateTest
stateKey="some-key"
defaultValue={null}
senderRole="detacher"
targetRole="detached"
handleClick={setValue => {
setValue('barbaz1')
}}
/>
</EditorProviders>
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.get('#setValue').click()
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detacher',
event: 'state-some-key',
data: { value: 'barbaz1' },
})
})
it('receive message as target', function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
cy.mount(
<EditorProviders>
<DetachStateTest
stateKey="some-key"
defaultValue={null}
senderRole="detacher"
targetRole="detached"
handleClick={() => {}}
/>
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'state-some-key',
data: { value: 'barbaz2' },
})
})
cy.get('#value').should('have.text', 'barbaz2')
})
})

View File

@@ -0,0 +1,157 @@
import PropTypes from 'prop-types'
import { expect } from 'chai'
import { render } from '@testing-library/react'
import useExpandCollapse from '../../../../frontend/js/shared/hooks/use-expand-collapse'
const sampleContent = (
<div>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<p>Maecenas maximus ultrices sollicitudin.</p>
<p>Praesent mollis arcu eget molestie viverra.</p>
<p>Pellentesque eget molestie nisl, non hendrerit lectus.</p>
</div>
)
const originalScrollHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight'
)
const originalScrollWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth'
)
function ExpandCollapseTestUI({ expandCollapseArgs }) {
const { expandableProps } = useExpandCollapse(expandCollapseArgs)
return (
<>
<div {...expandableProps}>{sampleContent}</div>
</>
)
}
ExpandCollapseTestUI.propTypes = {
expandCollapseArgs: PropTypes.object,
}
describe('useExpandCollapse', function () {
// JSDom doesn't compute layout/sizing, so we need to simulate sizing for the elements
// Here we are simulating that the content is bigger than the `collapsedSize`, so
// the expand-collapse widget is used
beforeEach(function () {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 500,
})
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: 500,
})
})
afterEach(function () {
Object.defineProperty(
HTMLElement.prototype,
'scrollHeight',
originalScrollHeight
)
Object.defineProperty(
HTMLElement.prototype,
'scrollWidth',
originalScrollWidth
)
})
describe('custom CSS classes', function () {
it('supports a custom CSS class', function () {
const testArgs = {
classes: {
container: 'my-custom-class',
},
}
const { container } = render(
<ExpandCollapseTestUI expandCollapseArgs={testArgs} />
)
const elWithCustomCSSClass = container.querySelector('div')
expect(elWithCustomCSSClass).to.exist
})
it('supports an extra custom CSS class for the collapsed state', function () {
const testArgs = {
classes: {
containerCollapsed: 'my-custom-collapsed-class',
},
}
const { container } = render(
<ExpandCollapseTestUI expandCollapseArgs={testArgs} />
)
const elWithCustomCollapsedCSSClass = container.querySelector(
'.my-custom-collapsed-class'
)
expect(elWithCustomCollapsedCSSClass).to.exist
})
it('ignores the collapsed CSS class when expanded', function () {
const testArgs = {
initiallyExpanded: true,
classes: {
containerCollapsed: 'my-custom-collapsed-class',
},
}
const { container } = render(
<ExpandCollapseTestUI expandCollapseArgs={testArgs} />
)
const elWithCustomCollapsedCSSClass = container.querySelector(
'.my-custom-collapsed-class'
)
expect(elWithCustomCollapsedCSSClass).to.not.exist
})
})
describe('height and width support via dimension argument', function () {
it('defaults to height', function () {
const { container } = render(<ExpandCollapseTestUI />)
const expandCollapseEl = container.firstChild
expect(expandCollapseEl.style.height).to.not.be.empty
expect(expandCollapseEl.style.width).to.be.empty
})
it('supports width', function () {
const testArgs = {
dimension: 'width',
}
const { container } = render(
<ExpandCollapseTestUI expandCollapseArgs={testArgs} />
)
const expandCollapseEl = container.firstChild
expect(expandCollapseEl.style.height).to.be.empty
expect(expandCollapseEl.style.width).to.not.be.empty
})
})
describe('collapsed size support via collapsedSize argument', function () {
it('defaults to 0px', function () {
const { container } = render(<ExpandCollapseTestUI />)
const expandCollapseEl = container.firstChild
expect(expandCollapseEl.style.height).to.equal('0px')
})
it('supports a custom collapsed size', function () {
const testArgs = {
collapsedSize: 200,
}
const { container } = render(
<ExpandCollapseTestUI expandCollapseArgs={testArgs} />
)
const expandCollapseEl = container.firstChild
expect(expandCollapseEl.style.height).to.equal('200px')
})
it('supports a custom collapsed size for width', function () {
const testArgs = {
collapsedSize: 200,
dimension: 'width',
}
const { container } = render(
<ExpandCollapseTestUI expandCollapseArgs={testArgs} />
)
const expandCollapseEl = container.firstChild
expect(expandCollapseEl.style.height).to.be.empty
expect(expandCollapseEl.style.width).to.equal('200px')
})
})
})

View File

@@ -0,0 +1,183 @@
import sinon from 'sinon'
import { expect } from 'chai'
import { useEffect } from 'react'
import { render, screen } from '@testing-library/react'
import usePersistedState from '../../../../frontend/js/shared/hooks/use-persisted-state'
import localStorage from '@/infrastructure/local-storage'
describe('usePersistedState', function () {
beforeEach(function () {
sinon.spy(window.Storage.prototype, 'getItem')
sinon.spy(window.Storage.prototype, 'removeItem')
sinon.spy(window.Storage.prototype, 'setItem')
})
afterEach(function () {
sinon.restore()
})
it('reads the value from localStorage', function () {
const key = 'test'
localStorage.setItem(key, 'foo')
expect(window.Storage.prototype.setItem).to.have.callCount(1)
const Test = () => {
const [value] = usePersistedState(key)
return <div>{value}</div>
}
render(<Test />)
screen.getByText('foo')
expect(window.Storage.prototype.getItem).to.have.callCount(1)
expect(window.Storage.prototype.removeItem).to.have.callCount(0)
expect(window.Storage.prototype.setItem).to.have.callCount(1)
expect(localStorage.getItem(key)).to.equal('foo')
})
it('uses the default value without storing anything', function () {
const key = 'test:default'
const Test = () => {
const [value] = usePersistedState(key, 'foo')
return <div>{value}</div>
}
render(<Test />)
screen.getByText('foo')
expect(window.Storage.prototype.getItem).to.have.callCount(1)
expect(window.Storage.prototype.removeItem).to.have.callCount(0)
expect(window.Storage.prototype.setItem).to.have.callCount(0)
expect(localStorage.getItem(key)).to.be.null
})
it('stores the new value in localStorage', function () {
const key = 'test:store'
localStorage.setItem(key, 'foo')
expect(window.Storage.prototype.setItem).to.have.callCount(1)
const Test = () => {
const [value, setValue] = usePersistedState(key, 'bar')
useEffect(() => {
setValue('baz')
}, [setValue])
return <div>{value}</div>
}
render(<Test />)
screen.getByText('baz')
expect(window.Storage.prototype.getItem).to.have.callCount(1)
expect(window.Storage.prototype.removeItem).to.have.callCount(0)
expect(window.Storage.prototype.setItem).to.have.callCount(2)
expect(localStorage.getItem(key)).to.equal('baz')
})
it('removes the value from localStorage if it equals the default value', function () {
const key = 'test:store-default'
localStorage.setItem(key, 'foo')
expect(window.Storage.prototype.setItem).to.have.callCount(1)
const Test = () => {
const [value, setValue] = usePersistedState(key, 'bar')
useEffect(() => {
// set a different value
setValue('baz')
expect(localStorage.getItem(key)).to.equal('baz')
// set the default value again
setValue('bar')
}, [setValue])
return <div>{value}</div>
}
render(<Test />)
screen.getByText('bar')
expect(window.Storage.prototype.getItem).to.have.callCount(2)
expect(window.Storage.prototype.removeItem).to.have.callCount(1)
expect(window.Storage.prototype.setItem).to.have.callCount(2)
expect(localStorage.getItem(key)).to.be.null
})
it('handles function values', function () {
const key = 'test:store'
localStorage.setItem(key, 'foo')
expect(window.Storage.prototype.setItem).to.have.callCount(1)
const Test = () => {
const [value, setValue] = usePersistedState<string>(key)
useEffect(() => {
setValue(value => value + 'bar')
}, [setValue])
return <div>{value}</div>
}
render(<Test />)
screen.getByText('foobar')
expect(window.Storage.prototype.getItem).to.have.callCount(1)
expect(window.Storage.prototype.removeItem).to.have.callCount(0)
expect(window.Storage.prototype.setItem).to.have.callCount(2)
expect(localStorage.getItem(key)).to.equal('foobar')
})
it('handles syncing values via storage event', async function () {
const key = 'test:sync'
localStorage.setItem(key, 'foo')
expect(window.Storage.prototype.setItem).to.have.callCount(1)
// listen for storage events
const storageEventListener = sinon.stub()
window.addEventListener('storage', storageEventListener)
const Test = () => {
const [value, setValue] = usePersistedState(key, 'bar', true)
useEffect(() => {
setValue('baz')
}, [setValue])
return <div>{value}</div>
}
render(<Test />)
screen.getByText('baz')
expect(window.Storage.prototype.getItem).to.have.callCount(1)
expect(window.Storage.prototype.removeItem).to.have.callCount(0)
expect(window.Storage.prototype.setItem).to.have.callCount(2)
expect(localStorage.getItem(key)).to.equal('baz')
expect(storageEventListener).to.have.callCount(0)
// set the new value in localStorage
localStorage.setItem(key, 'cat')
// dispatch a "storage" event and check that it's picked up by the hook
window.dispatchEvent(new StorageEvent('storage', { key }))
await screen.findByText('cat')
expect(storageEventListener).to.have.callCount(1)
})
})

View File

@@ -0,0 +1,37 @@
import { useRecaptcha } from '@/shared/hooks/use-recaptcha'
import * as ReactGoogleRecaptcha from 'react-google-recaptcha'
const ReCaptcha = () => {
const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
const handleClick = async () => {
await getReCaptchaToken()
}
return (
<>
<ReactGoogleRecaptcha.ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey="123456"
badge="inline"
/>
<button onClick={handleClick}>Click</button>
</>
)
}
describe('useRecaptcha', function () {
it('should reset the captcha', function () {
cy.spy(ReactGoogleRecaptcha.ReCAPTCHA.prototype, 'reset').as('resetSpy')
cy.spy(ReactGoogleRecaptcha.ReCAPTCHA.prototype, 'executeAsync').as(
'executeAsyncSpy'
)
cy.mount(<ReCaptcha />)
cy.findByRole('button', { name: /click/i }).click()
cy.get('@resetSpy').should('have.been.calledOnce')
cy.get('@executeAsyncSpy').should('have.been.calledOnce')
})
})

View File

@@ -0,0 +1,115 @@
import {
usePersistedResize,
useResize,
} from '../../../../frontend/js/shared/hooks/use-resize'
function Template({
mousePos,
getTargetProps,
getHandleProps,
}: ReturnType<typeof useResize>) {
return (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
}}
>
<div style={{ position: 'relative' }}>
<div
id="target"
{...getTargetProps({
style: {
width: '200px',
height: '200px',
border: '2px solid black',
...(mousePos?.x && { width: `${mousePos.x}px` }),
},
})}
>
Demo content demo content demo content demo content demo content demo
content
</div>
<div
id="handle"
{...getHandleProps({
style: {
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: '4px',
backgroundColor: 'red',
},
})}
/>
</div>
</div>
)
}
function PersistedResizeTest() {
const props = usePersistedResize({ name: 'test' })
return <Template {...props} />
}
function ResizeTest() {
const props = useResize()
return <Template {...props} />
}
describe('useResize', function () {
it('should apply provided styles to the target', function () {
cy.mount(<ResizeTest />)
// test a css prop being applied
cy.get('#target').should('have.css', 'width', '200px')
})
it('should apply provided styles to the handle', function () {
cy.mount(<ResizeTest />)
// test a css prop being applied
cy.get('#handle').should('have.css', 'width', '4px')
})
it('should apply default styles to the handle', function () {
cy.mount(<ResizeTest />)
cy.get('#handle')
.should('have.css', 'cursor', 'col-resize')
.and('have.css', 'user-select', 'none')
})
it('should resize the target horizontally on mousedown and mousemove', function () {
const xPos = 400
cy.mount(<ResizeTest />)
cy.get('#handle').trigger('mousedown', { button: 0 })
cy.get('#handle').trigger('mousemove', { clientX: xPos })
cy.get('#handle').trigger('mouseup')
cy.get('#target').should('have.css', 'width', `${xPos}px`)
})
it('should persist the resize data', function () {
const xPos = 400
cy.mount(<PersistedResizeTest />)
cy.get('#handle').trigger('mousedown', { button: 0 })
cy.get('#handle').trigger('mousemove', { clientX: xPos })
cy.get('#handle').trigger('mouseup')
cy.window()
.its('localStorage.resizeable-test')
.should('eq', `{"x":${xPos}}`)
// render the component again
cy.mount(<PersistedResizeTest />)
cy.get('#target').should('have.css', 'width', `${xPos}px`)
})
})