feat: turn overlay functionalities into composable (#1597)

This commit is contained in:
Phan An 2022-11-19 19:04:21 +01:00 committed by GitHub
parent 34945a507c
commit 9c776cb3b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 93 additions and 146 deletions

View file

@ -1,5 +1,5 @@
<template>
<Overlay/>
<Overlay ref="overlay"/>
<DialogBox ref="dialog"/>
<MessageToaster ref="toaster"/>
<GlobalEventListeners/>
@ -26,10 +26,9 @@
<script lang="ts" setup>
import { defineAsyncComponent, nextTick, onMounted, provide, ref, watch } from 'vue'
import { hideOverlay, showOverlay } from '@/utils'
import { commonStore, preferenceStore as preferences, queueStore } from '@/stores'
import { authService, socketListener, socketService, uploadService } from '@/services'
import { CurrentSongKey, DialogBoxKey, MessageToasterKey } from '@/symbols'
import { CurrentSongKey, DialogBoxKey, MessageToasterKey, OverlayKey } from '@/symbols'
import { useNetworkStatus, useRouter } from '@/composables'
import DialogBox from '@/components/ui/DialogBox.vue'
@ -56,6 +55,7 @@ const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/compon
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
const overlay = ref<InstanceType<typeof Overlay>>()
const dialog = ref<InstanceType<typeof DialogBox>>()
const toaster = ref<InstanceType<typeof MessageToaster>>()
const currentSong = ref<Song | null>(null)
@ -92,7 +92,7 @@ onMounted(async () => {
})
const init = async () => {
showOverlay()
overlay.value.show()
try {
await commonStore.init()
@ -108,8 +108,7 @@ const init = async () => {
})
await socketService.init() && socketListener.listen()
hideOverlay()
overlay.value.hide()
} catch (err) {
authenticated.value = false
throw err
@ -125,6 +124,7 @@ watch(() => queueStore.current, song => song && (currentSong.value = song))
const onDragEnd = () => (showDropZone.value = false)
const onDrop = () => (showDropZone.value = false)
provide(OverlayKey, overlay)
provide(DialogBoxKey, dialog)
provide(MessageToasterKey, toaster)
provide(CurrentSongKey, currentSong)

View file

@ -6,8 +6,8 @@ import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
import { http } from '@/services'
import factory from '@/__tests__/factory'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
import { DialogBoxKey, MessageToasterKey, OverlayKey, RouterKey } from '@/symbols'
import { DialogBoxStub, MessageToasterStub, OverlayStub } from '@/__tests__/stubs'
import { routes } from '@/config'
import Router from '@/router'
@ -114,6 +114,12 @@ export default abstract class UnitTestCase {
options.global.provide[MessageToasterKey] = MessageToasterStub
}
// @ts-ignore
if (!options.global.provide.hasOwnProperty(OverlayKey)) {
// @ts-ignore
options.global.provide[OverlayKey] = OverlayStub
}
// @ts-ignore
if (!options.global.provide.hasOwnProperty(RouterKey)) {
// @ts-ignore

View file

@ -3,6 +3,7 @@ import { noop } from '@/utils'
import MessageToaster from '@/components/ui/MessageToaster.vue'
import DialogBox from '@/components/ui/DialogBox.vue'
import Overlay from '@/components/ui/Overlay.vue'
export const MessageToasterStub: Ref<InstanceType<typeof MessageToaster>> = ref({
info: noop,
@ -18,3 +19,8 @@ export const DialogBoxStub: Ref<InstanceType<typeof DialogBox>> = ref({
error: noop,
confirm: noop
})
export const OverlayStub: Ref<InstanceType<typeof Overlay>> = ref({
show: noop,
hide: noop
})

View file

@ -31,8 +31,8 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { settingStore } from '@/stores'
import { forceReloadWindow, hideOverlay, parseValidationError, showOverlay } from '@/utils'
import { useDialogBox, useMessageToaster, useRouter } from '@/composables'
import { forceReloadWindow, parseValidationError } from '@/utils'
import { useDialogBox, useMessageToaster, useOverlay, useRouter } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import Btn from '@/components/ui/Btn.vue'
@ -40,6 +40,7 @@ import Btn from '@/components/ui/Btn.vue'
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog, showErrorDialog } = useDialogBox()
const { go } = useRouter()
const { showOverlay, hideOverlay } = useOverlay()
const mediaPath = ref(settingStore.state.media_path)
const originalMediaPath = mediaPath.value

View file

@ -6,6 +6,7 @@ import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
import { ref } from 'vue'
import { fireEvent } from '@testing-library/vue'
import { songStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import EditSongForm from './EditSongForm.vue'
let songs: Song[]
@ -32,6 +33,7 @@ new class extends UnitTestCase {
it('edits a single song', async () => {
const updateMock = this.mock(songStore, 'update')
const emitMock = this.mock(eventBus, 'emit')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const { html, getByTestId, getByRole } = await this.renderComponent(factory<Song>('song', {
title: 'Rocket to Heaven',
@ -67,12 +69,14 @@ new class extends UnitTestCase {
year: 1971
})
expect(alertMock).toHaveBeenCalledWith('Updated 1 song.')
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
})
it('edits multiple songs', async () => {
const updateMock = this.mock(songStore, 'update')
const emitMock = this.mock(eventBus, 'emit')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song>('song', 3))
@ -100,6 +104,7 @@ new class extends UnitTestCase {
year: 1990
})
expect(alertMock).toHaveBeenCalledWith('Updated 3 songs.')
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
})

View file

@ -269,6 +269,7 @@ new class extends UnitTestCase {
it('deletes song', async () => {
const confirmMock = this.mock(DialogBoxStub.value, 'confirm', true)
const toasterMock = this.mock(MessageToasterStub.value, 'success')
const deleteMock = this.mock(songStore, 'deleteFromFilesystem')
const { getByText } = await this.actingAsAdmin().renderComponent()
@ -279,6 +280,7 @@ new class extends UnitTestCase {
await waitFor(() => {
expect(confirmMock).toHaveBeenCalled()
expect(deleteMock).toHaveBeenCalledWith(songs)
expect(toasterMock).toHaveBeenCalledWith('Deleted 5 songs from the filesystem.')
expect(emitMock).toHaveBeenCalledWith('SONGS_DELETED', songs)
})
})

View file

@ -1,48 +0,0 @@
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import { eventBus } from '@/utils'
import SoundBars from '@/components/ui/SoundBars.vue'
import Overlay from './Overlay.vue'
new class extends UnitTestCase {
private async renderComponent (type: OverlayState['type'] = 'loading') {
const rendered = this.render(Overlay, {
global: {
stubs: {
SoundBars
}
}
})
eventBus.emit('SHOW_OVERLAY', {
type,
message: 'Look at me now'
})
await this.tick()
return rendered
}
protected test () {
it.each<[OverlayState['type']]>([
['loading'],
['success'],
['info'],
['warning'],
['error']
])('renders %s type', async type => {
const { getByTestId, html } = await this.renderComponent(type)
expect(html()).toMatchSnapshot()
expect((getByTestId('overlay') as HTMLDialogElement).open).toBe(true)
})
it('closes', async () => {
const { getByTestId } = await this.renderComponent()
eventBus.emit('HIDE_OVERLAY')
expect((getByTestId('overlay') as HTMLDialogElement).open).toBe(false)
})
}
}

View file

@ -15,7 +15,6 @@
<script lang="ts" setup>
import { faCircleCheck, faCircleExclamation, faCircleInfo, faWarning } from '@fortawesome/free-solid-svg-icons'
import { defineAsyncComponent, reactive, ref } from 'vue'
import { eventBus } from '@/utils'
const SoundBars = defineAsyncComponent(() => import('@/components/ui/SoundBars.vue'))
@ -24,7 +23,7 @@ const el = ref<HTMLDialogElement>()
const state = reactive<OverlayState>({
dismissible: false,
type: 'loading',
message: ''
message: 'Just a little patience…'
})
const show = (options: Partial<OverlayState> = {}) => {
@ -35,8 +34,7 @@ const show = (options: Partial<OverlayState> = {}) => {
const hide = () => el.value?.close()
const onCancel = () => state.dismissible && hide()
eventBus.on('SHOW_OVERLAY', options => show(options))
.on('HIDE_OVERLAY', () => hide())
defineExpose({ show, hide })
</script>
<style lang="scss" scoped>

View file

@ -1,56 +0,0 @@
// Vitest Snapshot v1
exports[`renders error type 1`] = `
<dialog class="error" data-testid="overlay" data-v-889cac1d="" open="">
<div class="wrapper" data-v-889cac1d="">
<!--v-if--><br data-testid="icon" icon="[object Object]" data-v-889cac1d="">
<!--v-if-->
<!--v-if-->
<!--v-if--><span class="message" data-v-889cac1d="">Look at me now</span>
</div>
</dialog>
`;
exports[`renders info type 1`] = `
<dialog class="info" data-testid="overlay" data-v-889cac1d="" open="">
<div class="wrapper" data-v-889cac1d="">
<!--v-if-->
<!--v-if-->
<!--v-if--><br data-testid="icon" icon="[object Object]" data-v-889cac1d="">
<!--v-if--><span class="message" data-v-889cac1d="">Look at me now</span>
</div>
</dialog>
`;
exports[`renders loading type 1`] = `
<dialog class="loading" data-testid="overlay" data-v-889cac1d="" open="">
<div class="wrapper" data-v-889cac1d=""><i data-v-47e95701="" data-v-889cac1d=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if--><span class="message" data-v-889cac1d="">Look at me now</span>
</div>
</dialog>
`;
exports[`renders success type 1`] = `
<dialog class="success" data-testid="overlay" data-v-889cac1d="" open="">
<div class="wrapper" data-v-889cac1d="">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if--><br data-testid="icon" icon="[object Object]" data-v-889cac1d=""><span class="message" data-v-889cac1d="">Look at me now</span>
</div>
</dialog>
`;
exports[`renders warning type 1`] = `
<dialog class="warning" data-testid="overlay" data-v-889cac1d="" open="">
<div class="wrapper" data-v-889cac1d="">
<!--v-if-->
<!--v-if--><br data-testid="icon" icon="[object Object]" data-v-889cac1d="">
<!--v-if-->
<!--v-if--><span class="message" data-v-889cac1d="">Look at me now</span>
</div>
</dialog>
`;

View file

@ -2,12 +2,14 @@ import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { fireEvent, waitFor } from '@testing-library/vue'
import { userStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import AddUserForm from './AddUserForm.vue'
new class extends UnitTestCase {
protected test () {
it('creates a new user', async () => {
const storeMock = this.mock(userStore, 'store')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const { getByLabelText, getByRole } = this.render(AddUserForm)
@ -24,6 +26,8 @@ new class extends UnitTestCase {
password: 'secret-password',
is_admin: true
})
expect(alertMock).toHaveBeenCalledWith('New user "John Doe" created.')
})
})
}

View file

@ -5,12 +5,14 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { UserKey } from '@/symbols'
import { fireEvent, waitFor } from '@testing-library/vue'
import { userStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import EditUserForm from './EditUserForm.vue'
new class extends UnitTestCase {
protected test () {
it('edits a user', async () => {
const updateMock = this.mock(userStore, 'update')
const alertMock = this.mock(MessageToasterStub.value, 'success')
const user = ref(factory<User>('user', { name: 'John Doe' }))
@ -33,6 +35,8 @@ new class extends UnitTestCase {
is_admin: user.value.is_admin,
password: 'new-password-duck'
})
expect(alertMock).toHaveBeenCalledWith('User profile updated.')
})
})
}

View file

@ -2,7 +2,7 @@
* Global event listeners (basically, those without a Vue instance access) go here.
*/
import { defineComponent } from 'vue'
import { defineComponent, onMounted } from 'vue'
import { authService } from '@/services'
import { playlistFolderStore, playlistStore, userStore } from '@/stores'
import { eventBus, forceReloadWindow } from '@/utils'
@ -10,9 +10,15 @@ import { useDialogBox, useMessageToaster, useRouter } from '@/composables'
export const GlobalEventListeners = defineComponent({
setup (props, { slots }) {
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
const { go } = useRouter()
let toastSuccess: ReturnType<typeof useMessageToaster>['toastSuccess']
let showConfirmDialog: ReturnType<typeof useDialogBox>['showConfirmDialog']
let go: ReturnType<typeof useRouter>['go']
onMounted(() => {
toastSuccess = useMessageToaster().toastSuccess
showConfirmDialog = useDialogBox().showConfirmDialog
go = useRouter().go
})
eventBus.on('PLAYLIST_DELETE', async playlist => {
if (await showConfirmDialog(`Delete the playlist "${playlist.name}"?`)) {

View file

@ -7,6 +7,7 @@ export * from './useInfiniteScroll'
export * from './useMessageToaster'
export * from './useNetworkStatus'
export * from './useNewVersionNotification'
export * from './useOverlay'
export * from './usePlaylistManagement'
export * from './useRouter'
export * from './useSmartPlaylistForm'

View file

@ -1,14 +1,18 @@
import { Ref } from 'vue'
import { requireInjection } from '@/utils'
import { DialogBoxKey } from '@/symbols'
import DialogBox from '@/components/ui/DialogBox.vue'
let dialogBox: Ref<InstanceType<typeof DialogBox>>
export const useDialogBox = () => {
const dialogBox = requireInjection(DialogBoxKey)
dialogBox = dialogBox || requireInjection(DialogBoxKey)
return {
showSuccessDialog: (message: string, title: string = '') => dialogBox.value.success(message, title),
showInfoDialog: (message: string, title: string = '') => dialogBox.value.info(message, title),
showWarningDialog: (message: string, title: string = '') => dialogBox.value.warning(message, title),
showErrorDialog: (message: string, title: string = '') => dialogBox.value.error(message, title),
showConfirmDialog: (message: string, title: string = '') => dialogBox.value.confirm(message, title)
showSuccessDialog: dialogBox.value.success.bind(dialogBox.value),
showInfoDialog: dialogBox.value.info.bind(dialogBox.value),
showWarningDialog: dialogBox.value.warning.bind(dialogBox.value),
showErrorDialog: dialogBox.value.error.bind(dialogBox.value),
showConfirmDialog: dialogBox.value.confirm.bind(dialogBox.value)
}
}

View file

@ -1,13 +1,17 @@
import { Ref } from 'vue'
import { MessageToasterKey } from '@/symbols'
import { requireInjection } from '@/utils'
import MessageToaster from '@/components/ui/MessageToaster.vue'
let toaster: Ref<InstanceType<typeof MessageToaster>>
export const useMessageToaster = () => {
const toaster = requireInjection(MessageToasterKey)
toaster = toaster || requireInjection(MessageToasterKey)
return {
toastSuccess: (content: string, timeout?: number) => toaster.value.success(content, timeout),
toastInfo: (content: string, timeout?: number) => toaster.value.info(content, timeout),
toastWarning: (content: string, timeout?: number) => toaster.value.warning(content, timeout),
toastError: (content: string, timeout?: number) => toaster.value.error(content, timeout)
toastSuccess: toaster.value.success.bind(toaster.value),
toastInfo: toaster.value.info.bind(toaster.value),
toastWarning: toaster.value.warning.bind(toaster.value),
toastError: toaster.value.error.bind(toaster.value)
}
}

View file

@ -0,0 +1,15 @@
import { Ref } from 'vue'
import { requireInjection } from '@/utils'
import { OverlayKey } from '@/symbols'
import Overlay from '@/components/ui/Overlay.vue'
let overlay: Ref<InstanceType<typeof Overlay>>
export const useOverlay = () => {
overlay = overlay || requireInjection(OverlayKey)
return {
showOverlay: overlay.value.show.bind(overlay.value),
hideOverlay: overlay.value.hide.bind(overlay.value)
}
}

View file

@ -1,8 +1,11 @@
import { RouterKey } from '@/symbols'
import { requireInjection } from '@/utils'
import Router from '@/router'
let router: Router
export const useRouter = () => {
const router = requireInjection(RouterKey)
router = router || requireInjection(RouterKey)
const getRouteParam = (name: string) => router.$currentRoute.value?.params?.[name]
const getCurrentScreen = () => router.$currentRoute.value?.screen
@ -20,6 +23,6 @@ export const useRouter = () => {
go: router.go.bind(router),
onRouteChanged: router.onRouteChanged.bind(router),
resolveRoute: router.resolve.bind(router),
triggerNotFound: router.triggerNotFound.bind(router),
triggerNotFound: router.triggerNotFound.bind(router)
}
}

View file

@ -3,8 +3,6 @@ import { Ref } from 'vue'
export interface Events {
LOG_OUT: () => void
TOGGLE_SIDEBAR: () => void
SHOW_OVERLAY: (options: Partial<OverlayState>) => void
HIDE_OVERLAY: () => void
FOCUS_SEARCH_FIELD: () => void
PLAY_YOUTUBE_VIDEO: (payload: { id: string, title: string }) => void
SEARCH_KEYWORDS_CHANGED: (keywords: string) => void

View file

@ -1,4 +1,5 @@
import { DeepReadonly, InjectionKey, Ref } from 'vue'
import Overlay from '@/components/ui/Overlay.vue'
import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
import Router from '@/router'
@ -6,6 +7,7 @@ import Router from '@/router'
export type ReadonlyInjectionKey<T> = InjectionKey<[Readonly<T> | DeepReadonly<T>, Closure]>
export const RouterKey: InjectionKey<Router> = Symbol('Router')
export const OverlayKey: InjectionKey<Ref<InstanceType<typeof Overlay>>> = Symbol('Overlay')
export const DialogBoxKey: InjectionKey<Ref<InstanceType<typeof DialogBox>>> = Symbol('DialogBox')
export const MessageToasterKey: InjectionKey<Ref<InstanceType<typeof MessageToaster>>> = Symbol('MessageToaster')

View file

@ -1,5 +1,5 @@
import select from 'select'
import { eventBus, noop } from '@/utils'
import { noop } from '@/utils'
import defaultCover from '@/../img/covers/default.svg'
export { defaultCover }
@ -17,14 +17,6 @@ export const forceReloadWindow = (): void => {
window.location.reload()
}
export const showOverlay = (
message = 'Just a little patience…',
type: OverlayState['type'] = 'loading',
dismissible = false
) => eventBus.emit('SHOW_OVERLAY', { message, type, dismissible })
export const hideOverlay = () => eventBus.emit('HIDE_OVERLAY')
export const copyText = (text: string): void => {
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea')