feat: use a better ESLint setup (#1850)

This commit is contained in:
Phan An 2024-10-14 00:37:01 +07:00 committed by GitHub
parent 9cb99af0e4
commit 5f37982641
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
384 changed files with 4844 additions and 3455 deletions

View file

@ -1,52 +0,0 @@
{
"parser": "vue-eslint-parser",
"env": {
"browser": true
},
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2020
},
"extends": [
"plugin:vue/vue3-recommended"
],
"ignorePatterns": [
"cypress/fixtures",
"cypress/screenshots",
"resources/assets/js/tests/__coverage__"
],
"plugins": [
"@typescript-eslint"
],
"globals": {
"FileReader": "readonly",
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
},
"rules": {
"camelcase": 0,
"no-multi-str": 0,
"no-empty": 0,
"quotes": 0,
"no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/member-delimiter-style": 0,
"@typescript-eslint/consistent-type-assertions": 0,
"@typescript-eslint/no-inferrable-types": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"standard/no-callback-literal": 0,
"vue/valid-v-on": 0,
"vue/no-side-effects-in-computed-properties": 0,
"vue/max-attributes-per-line": 0,
"vue/no-v-html": 0,
"vue/singleline-html-element-content-newline": 0,
"vue/multi-word-component-names": 0
}
}

49
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,49 @@
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}

31
eslint.config.js Normal file
View file

@ -0,0 +1,31 @@
import config from '@antfu/eslint-config'
export default config(
{
formatters: true,
},
{
files: ['**/*.ts', '**/*.vue'],
rules: {
'antfu/top-level-function': 'off',
'curly': ['error', 'all'],
'no-case-declarations': 'off',
'no-multi-str': 'off',
'no-new': 'off',
'perfectionist/sort-imports': 'off',
'style/arrow-parens': ['error', 'as-needed'],
'style/brace-style': ['error', '1tbs'],
'style/new-parens': 'off',
'node/prefer-global/process': ['error', 'always'],
'style/space-before-function-paren': ['error', 'always'],
'ts/ban-ts-comment': 'off',
'vue/block-order': ['error', { 'order': ['template', 'script', 'style'] }],
'vue/no-lone-template': 'off',
'vue/singleline-html-element-content-newline': 'off',
},
},
).prepend({
ignores: [
'resources/assets/js/visualizers/**',
]
})

View file

@ -39,6 +39,7 @@
"youtube-player": "^3.0.4"
},
"devDependencies": {
"@antfu/eslint-config": "^3.7.3",
"@commitlint/cli": "^19.4.0",
"@commitlint/config-conventional": "^19.2.2",
"@faker-js/faker": "^6.2.0",
@ -53,8 +54,6 @@
"@types/pusher-js": "^4.2.2",
"@types/three": "^0.144.0",
"@types/youtube-player": "^5.5.2",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^4.11.1",
"@vitejs/plugin-vue": "^5.0.4",
"@vueuse/components": "^10.9.0",
"@vueuse/core": "^10.9.0",
@ -63,12 +62,8 @@
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
"cypress": "^9.5.4",
"eslint": "^8.14.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^8.7.1",
"eslint": "^9.11.1",
"eslint-plugin-format": "^0.1.2",
"events": "^3.3.0",
"factoria": "^4.0.0",
"husky": "^9.1.5",
@ -92,7 +87,7 @@
"yarn": "^1.22.22"
},
"scripts": {
"lint": "eslint ./resources/assets/js/**/*.ts --no-error-on-unmatched-pattern && eslint ./cypress/**/*.ts --no-error-on-unmatched-pattern",
"lint": "eslint ./resources/assets/js --fix",
"test": "vitest",
"test:unit": "vitest",
"test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'",
@ -109,11 +104,11 @@
"**/*.php": [
"composer cs"
],
"resources/assets/**/*.ts": [
"eslint"
"resources/assets/js/**/*.{ts,vue}": [
"eslint --fix"
],
"cypress/**/*.ts": [
"eslint"
"eslint --fix"
]
},
"type": "module",

View file

@ -1,6 +1,7 @@
import isMobile from 'ismobilejs'
import { isObject, mergeWith } from 'lodash'
import { cleanup, createEvent, fireEvent, render, RenderOptions } from '@testing-library/vue'
import type { RenderOptions } from '@testing-library/vue'
import { cleanup, createEvent, fireEvent, render } from '@testing-library/vue'
import { afterEach, beforeEach, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
@ -11,8 +12,8 @@ import { DialogBoxStub, MessageToasterStub, OverlayStub } from '@/__tests__/stub
import { routes } from '@/config'
import Router from '@/router'
import userEvent from '@testing-library/user-event'
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'
import { EventType } from '@testing-library/dom/types/events'
import type { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'
import type { EventType } from '@testing-library/dom/types/events'
// A deep-merge function that
// - supports symbols as keys (_.merge doesn't)
@ -20,7 +21,9 @@ import { EventType } from '@testing-library/dom/types/events'
// Credit: https://stackoverflow.com/a/60598589/794641
const deepMerge = (first: object, second: object) => {
return mergeWith(first, second, (a, b) => {
if (!isObject(b)) return b
if (!isObject(b)) {
return b
}
// @ts-ignore
return Array.isArray(a) ? [...a, ...b] : { ...a, ...b }
@ -28,7 +31,9 @@ const deepMerge = (first: object, second: object) => {
}
const setPropIfNotExists = (obj: object | null, prop: any, value: any) => {
if (!obj) return
if (!obj) {
return
}
if (!Object.prototype.hasOwnProperty.call(obj, prop)) {
obj[prop] = value
@ -46,7 +51,7 @@ export default abstract class UnitTestCase {
this.user = userEvent.setup({ delay: null }) // @see https://github.com/testing-library/user-event/issues/833
this.setReadOnlyProperty(navigator, 'clipboard', {
writeText: vi.fn()
writeText: vi.fn(),
})
this.beforeEach()
@ -113,12 +118,12 @@ export default abstract class UnitTestCase {
'koel-focus': {},
'koel-tooltip': {},
'koel-hide-broken-icon': {},
'koel-overflow-fade': {}
'koel-overflow-fade': {},
},
components: {
Icon: this.stub('Icon')
}
}
Icon: this.stub('Icon'),
},
},
}, this.supplyRequiredProvides(options)))
}
@ -148,7 +153,7 @@ export default abstract class UnitTestCase {
protected stub (testId = 'stub') {
return defineComponent({
template: `<br data-testid="${testId}"/>`
template: `<br data-testid="${testId}"/>`,
})
}
@ -162,8 +167,8 @@ export default abstract class UnitTestCase {
return Object.defineProperties(obj, {
[prop]: {
value,
configurable: true
}
configurable: true,
},
})
}
@ -172,7 +177,7 @@ export default abstract class UnitTestCase {
await this.user.type(element, value)
}
protected async trigger (element: HTMLElement, key: EventType | string, options?: {}) {
protected async trigger (element: HTMLElement, key: EventType | string, options?: object = {}) {
await fireEvent(element, createEvent[key](element, options))
}

View file

@ -1,4 +1,4 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Album => {
return {
@ -8,7 +8,7 @@ export default (faker: Faker): Album => {
id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default
name: faker.lorem.sentence(),
cover: faker.image.imageUrl(),
created_at: faker.date.past().toISOString()
created_at: faker.date.past().toISOString(),
}
}
@ -17,6 +17,6 @@ export const states: Record<string, Omit<Partial<Album>, 'type'>> = {
id: 1,
name: 'Unknown Album',
artist_id: 1,
artist_name: 'Unknown Artist'
}
artist_name: 'Unknown Artist',
},
}

View file

@ -1,12 +1,12 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
import factory from 'factoria'
export default (faker: Faker): AlbumInfo => ({
cover: faker.image.imageUrl(),
wiki: {
summary: faker.lorem.sentence(),
full: faker.lorem.sentences(4)
full: faker.lorem.sentences(4),
},
tracks: factory('album-track', 8),
url: faker.internet.url()
url: faker.internet.url(),
})

View file

@ -1,6 +1,6 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): AlbumTrack => ({
title: faker.lorem.sentence(),
length: faker.datatype.number({ min: 180, max: 1_800 })
length: faker.datatype.number({ min: 180, max: 1_800 }),
})

View file

@ -1,4 +1,4 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Artist => {
return {
@ -6,17 +6,17 @@ export default (faker: Faker): Artist => {
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
name: faker.name.findName(),
image: 'foo.jpg',
created_at: faker.date.past().toISOString()
created_at: faker.date.past().toISOString(),
}
}
export const states: Record<string, Omit<Partial<Artist>, 'type'>> = {
unknown: {
id: 1,
name: 'Unknown Artist'
name: 'Unknown Artist',
},
various: {
id: 2,
name: 'Various Artists'
}
name: 'Various Artists',
},
}

View file

@ -1,10 +1,10 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): ArtistInfo => ({
image: faker.image.imageUrl(),
bio: {
summary: faker.lorem.sentence(),
full: faker.lorem.sentences(4)
full: faker.lorem.sentences(4),
},
url: faker.internet.url()
url: faker.internet.url(),
})

View file

@ -1,4 +1,4 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Episode => {
return {
@ -15,6 +15,6 @@ export default (faker: Faker): Episode => {
episode_image: faker.image.imageUrl(),
podcast_id: faker.datatype.uuid(),
podcast_title: faker.lorem.sentence(),
podcast_author: faker.name.findName()
podcast_author: faker.name.findName(),
}
}

View file

@ -1,4 +1,4 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
import { genres } from '@/config'
export default (faker: Faker): Genre => {
@ -6,6 +6,6 @@ export default (faker: Faker): Genre => {
type: 'genres',
name: faker.helpers.arrayElement(genres),
song_count: faker.datatype.number({ min: 1, max: 1_000 }),
length: faker.datatype.number({ min: 300, max: 300_000 })
length: faker.datatype.number({ min: 300, max: 300_000 }),
}
}

View file

@ -1,4 +1,5 @@
import factoria, { Factoria } from 'factoria'
import type { Factoria } from 'factoria'
import factoria from 'factoria'
import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory'
import songFactory, { states as songStates } from '@/__tests__/factory/songFactory'
import albumFactory, { states as albumStates } from '@/__tests__/factory/albumFactory'
@ -16,26 +17,26 @@ import genreFactory from '@/__tests__/factory/genreFactory'
import playlistCollaboratorFactory from '@/__tests__/factory/playlistCollaboratorFactory'
import episodeFactory from '@/__tests__/factory/episodeFactory'
import podcastFactory from '@/__tests__/factory/podcastFactory'
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
type ModelToTypeMap = {
artist: Artist
interface ModelToTypeMap {
'artist': Artist
'artist-info': ArtistInfo
album: Album
'album': Album
'album-track': AlbumTrack
'album-info': AlbumInfo
song: Song
interaction: Interaction
genre: Genre
video: YouTubeVideo
'song': Song
'interaction': Interaction
'genre': Genre
'video': YouTubeVideo
'smart-playlist-rule': SmartPlaylistRule
'smart-playlist-rule-group': SmartPlaylistRuleGroup
playlist: Playlist
'playlist': Playlist
'playlist-folder': PlaylistFolder
user: User
'user': User
'playlist-collaborator': PlaylistCollaborator
episode: Episode
podcast: Podcast
'episode': Episode
'podcast': Podcast
}
type Model = keyof ModelToTypeMap
@ -44,30 +45,30 @@ type Overrides<M extends Model> = Factoria.Overrides<ModelToTypeMap[M]>
const define = <M extends Model>(
model: M,
handle: (faker: Faker) => Overrides<M>,
states?: Record<string, Factoria.StateDefinition>
states?: Record<string, Factoria.StateDefinition>,
) => factoria.define(model, handle, states)
function factory <M extends Model>(
function factory<M extends Model> (
model: M,
overrides?: Overrides<M>
): ModelToTypeMap[M]
function factory <M extends Model>(
function factory<M extends Model> (
model: M,
count: 1,
overrides?: Overrides<M>
): ModelToTypeMap[M]
function factory <M extends Model>(
function factory<M extends Model> (
model: M,
count: number,
overrides?: Overrides<M>
): ModelToTypeMap[M][]
function factory <M extends Model>(
function factory<M extends Model> (
model: M,
count: number|Overrides<M> = 1,
overrides?: Overrides<M>
count: number | Overrides<M> = 1,
overrides?: Overrides<M>,
) {
return typeof count === 'number'
? count === 1 ? factoria(model, overrides) : factoria(model, count, overrides)

View file

@ -1,9 +1,9 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Interaction => ({
type: 'interactions',
id: faker.datatype.number({ min: 1 }),
song_id: faker.datatype.uuid(),
liked: faker.datatype.boolean(),
play_count: faker.datatype.number({ min: 1 })
play_count: faker.datatype.number({ min: 1 }),
})

View file

@ -1,8 +1,8 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): PlaylistCollaborator => ({
type: 'playlist-collaborators',
id: faker.datatype.number(),
name: faker.name.findName(),
avatar: 'https://gravatar.com/foo'
avatar: 'https://gravatar.com/foo',
})

View file

@ -1,5 +1,5 @@
import factory from 'factoria'
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Playlist => ({
type: 'playlists',
@ -18,10 +18,10 @@ export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 't
smart: _ => ({
is_smart: true,
rules: [
factory('smart-playlist-rule-group')
]
factory('smart-playlist-rule-group'),
],
}),
orphan: _ => ({
folder_id: null
})
folder_id: null,
}),
}

View file

@ -1,7 +1,7 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): PlaylistFolder => ({
type: 'playlist-folders',
id: faker.datatype.uuid(),
name: faker.random.word()
name: faker.random.word(),
})

View file

@ -1,4 +1,4 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Podcast => {
return {
@ -14,7 +14,7 @@ export default (faker: Faker): Podcast => {
created_at: faker.date.past().toISOString(),
state: {
current_episode: null,
progresses: {}
}
progresses: {},
},
}
}

View file

@ -1,9 +1,9 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
import models from '@/config/smart-playlist/models'
export default (faker: Faker): SmartPlaylistRule => ({
id: faker.datatype.uuid(),
model: faker.helpers.arrayElement(models),
operator: faker.helpers.arrayElement<SmartPlaylistOperator['operator']>(['is', 'contains', 'isNot']),
value: [faker.random.word()]
value: [faker.random.word()],
})

View file

@ -1,7 +1,7 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
import factory from 'factoria'
export default (faker: Faker): SmartPlaylistRuleGroup => ({
id: faker.datatype.uuid(),
rules: factory('smart-playlist-rule', 3)
rules: factory('smart-playlist-rule', 3),
})

View file

@ -27,12 +27,12 @@ const generate = (partOfCompilation = false): Song => {
liked: faker.datatype.boolean(),
is_public: faker.datatype.boolean(),
created_at: faker.date.past().toISOString(),
playback_state: 'Stopped'
playback_state: 'Stopped',
}
}
export default (): Song => generate()
export const states: Record<string, Partial<Song>> = {
partOfCompilation: generate(true)
partOfCompilation: generate(true),
}

View file

@ -1,4 +1,4 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): User => ({
type: 'users',
@ -11,14 +11,14 @@ export default (faker: Faker): User => ({
avatar: 'https://gravatar.com/foo',
preferences: undefined,
sso_provider: null,
sso_id: null
sso_id: null,
})
export const states: Record<string, Omit<Partial<User>, 'type'>> = {
admin: {
is_admin: true
is_admin: true,
},
prospect: {
is_prospect: true
}
is_prospect: true,
},
}

View file

@ -1,16 +1,16 @@
import { Faker } from '@faker-js/faker'
import type { Faker } from '@faker-js/faker'
export default (faker: Faker): YouTubeVideo => ({
id: {
videoId: faker.random.alphaNumeric(16)
videoId: faker.random.alphaNumeric(16),
},
snippet: {
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
thumbnails: {
default: {
url: faker.image.imageUrl()
}
}
}
url: faker.image.imageUrl(),
},
},
},
})

View file

@ -19,18 +19,17 @@ declare global {
expect.addSnapshotSerializer(vueSnapshotSerializer)
global.ResizeObserver = global.ResizeObserver ||
vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn()
}))
globalThis.ResizeObserver = globalThis.ResizeObserver
|| vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}))
global.LemonSqueezy = {
globalThis.LemonSqueezy = {
Url: {
Open: vi.fn()
}
Open: vi.fn(),
},
}
HTMLMediaElement.prototype.load = vi.fn()

View file

@ -1,15 +1,16 @@
import { Ref, ref } from 'vue'
import type { Ref } from 'vue'
import { ref } from 'vue'
import { noop } from '@/utils'
import MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
import DialogBox from '@/components/ui/DialogBox.vue'
import Overlay from '@/components/ui/Overlay.vue'
import type MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
import type DialogBox from '@/components/ui/DialogBox.vue'
import type Overlay from '@/components/ui/Overlay.vue'
export const MessageToasterStub = ref({
info: noop,
success: noop,
warning: noop,
error: noop
error: noop,
}) as unknown as Ref<InstanceType<typeof MessageToaster>>
export const DialogBoxStub = ref({
@ -17,10 +18,10 @@ export const DialogBoxStub = ref({
success: noop,
warning: noop,
error: noop,
confirm: noop
confirm: noop,
}) as unknown as Ref<InstanceType<typeof DialogBox>>
export const OverlayStub = ref({
show: noop,
hide: noop
hide: noop,
}) as unknown as Ref<InstanceType<typeof Overlay>>

View file

@ -56,18 +56,18 @@ new class extends UnitTestCase {
id: 42,
name: 'IV',
artist_id: 17,
artist_name: 'Led Zeppelin'
artist_name: 'Led Zeppelin',
})
return this.render(AlbumCard, {
props: {
album
album,
},
global: {
stubs: {
AlbumArtistThumbnail: this.stub('thumbnail')
}
}
AlbumArtistThumbnail: this.stub('thumbnail'),
},
},
})
}
}

View file

@ -39,10 +39,10 @@ import { useDraggable, useRouter } from '@/composables'
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { go } = useRouter()
const { startDragging } = useDraggable('album')
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { album, layout } = toRefs(props)
// We're not checking for supports_batch_downloading here, as the number of songs on the album is not yet known.

View file

@ -85,7 +85,7 @@ new class extends UnitTestCase {
private async renderComponent (_album?: Album) {
album = _album || factory('album', {
name: 'IV'
name: 'IV',
})
const rendered = this.render(AlbumContextMenu)

View file

@ -10,7 +10,7 @@ let album: Album
new class extends UnitTestCase {
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async mode => {
await this.renderComponent(mode)
screen.getByTestId('album-info-tracks')
@ -38,14 +38,14 @@ new class extends UnitTestCase {
const rendered = this.render(AlbumInfoComponent, {
props: {
album,
mode
mode,
},
global: {
stubs: {
TrackList: this.stub(),
AlbumThumbnail: this.stub('thumbnail')
}
}
AlbumThumbnail: this.stub('thumbnail'),
},
},
})
await this.tick(1)

View file

@ -40,9 +40,10 @@ import AlbumThumbnail from '@/components/ui/album-artist/AlbumOrArtistThumbnail.
import AlbumArtistInfo from '@/components/ui/album-artist/AlbumOrArtistInfo.vue'
import ExpandableContentBlock from '@/components/ui/album-artist/ExpandableContentBlock.vue'
const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
const { album, mode } = toRefs(props)
const { useLastfm, useSpotify } = useThirdPartyServices()

View file

@ -14,8 +14,8 @@ new class extends UnitTestCase {
this.render(AlbumTrackList, {
props: {
album,
tracks: factory('album-track', 3)
}
tracks: factory('album-track', 3),
},
})
await this.tick()

View file

@ -30,7 +30,7 @@ new class extends UnitTestCase {
const track = factory('album-track', {
title: 'Fahrstuhl to Heaven',
length: 280
length: 280,
})
const matchMock = this.mock(songStore, 'match', matchedSong)
@ -38,13 +38,13 @@ new class extends UnitTestCase {
const rendered = this.render(AlbumTrackListItem, {
props: {
album,
track
track,
},
global: {
provide: {
[<symbol>PlayablesKey]: ref(songsToMatchAgainst)
}
}
[<symbol>PlayablesKey]: ref(songsToMatchAgainst),
},
},
})
expect(matchMock).toHaveBeenCalledWith('Fahrstuhl to Heaven', songsToMatchAgainst)

View file

@ -13,16 +13,18 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, Ref, toRefs } from 'vue'
import type { Ref } from 'vue'
import { computed, defineAsyncComponent, toRefs } from 'vue'
import { songStore } from '@/stores'
import { authService, playbackService } from '@/services'
import { useThirdPartyServices } from '@/composables'
import { requireInjection, secondsToHis } from '@/utils'
import { PlayablesKey } from '@/symbols'
const props = defineProps<{ album: Album, track: AlbumTrack }>()
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
const props = defineProps<{ album: Album, track: AlbumTrack }>()
const { album, track } = toRefs(props)
const { useAppleMusic } = useThirdPartyServices()
@ -44,7 +46,8 @@ const play = () => matchedSong.value && playbackService.play(matchedSong.value)
<style lang="postcss" scoped>
.track-list-item {
&:focus, &.active {
&:focus,
&.active {
span.title {
@apply text-k-highlight;
}

View file

@ -14,7 +14,7 @@ new class extends UnitTestCase {
super.beforeEach(() => {
artist = factory('artist', {
id: 42,
name: 'Led Zeppelin'
name: 'Led Zeppelin',
})
})
}
@ -63,13 +63,13 @@ new class extends UnitTestCase {
private renderComponent () {
return this.render(ArtistCard, {
props: {
artist
artist,
},
global: {
stubs: {
AlbumArtistThumbnail: this.stub('thumbnail')
}
}
AlbumArtistThumbnail: this.stub('thumbnail'),
},
},
})
}
}

View file

@ -31,10 +31,10 @@ import { useDraggable, useRouter } from '@/composables'
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { go } = useRouter()
const { startDragging } = useDraggable('artist')
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { artist, layout } = toRefs(props)
// We're not checking for supports_batch_downloading here, as the number of songs by the artist is not yet known.

View file

@ -82,7 +82,7 @@ new class extends UnitTestCase {
private async renderComponent (_artist?: Artist) {
artist = _artist || factory('artist', {
name: 'Accept'
name: 'Accept',
})
const rendered = this.render(ArtistContextMenu)

View file

@ -30,7 +30,7 @@ const allowDownload = toRef(commonStore.state, 'allows_download')
const isStandardArtist = computed(() =>
!artistStore.isUnknown(artist.value!)
&& !artistStore.isVarious(artist.value!)
&& !artistStore.isVarious(artist.value!),
)
const play = () => trigger(async () => {

View file

@ -10,7 +10,7 @@ let artist: Artist
new class extends UnitTestCase {
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async mode => {
await this.renderComponent(mode)
if (mode === 'aside') {
@ -33,13 +33,13 @@ new class extends UnitTestCase {
const rendered = this.render(ArtistInfoComponent, {
props: {
artist,
mode
mode,
},
global: {
stubs: {
ArtistThumbnail: this.stub('thumbnail')
}
}
ArtistThumbnail: this.stub('thumbnail'),
},
},
})
await this.tick(1)

View file

@ -30,10 +30,10 @@ import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'cancel'): void }>()
const { handleHttpError } = useErrorHandler()
const { toastSuccess } = useMessageToaster()
const emit = defineEmits<{ (e: 'cancel'): void }>()
const email = ref('')
const loading = ref(false)

View file

@ -1,5 +1,6 @@
import { screen, waitFor } from '@testing-library/vue'
import { expect, it, Mock } from 'vitest'
import type { Mock } from 'vitest'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { authService } from '@/services'
import { logger } from '@/utils'
@ -45,9 +46,9 @@ new class extends UnitTestCase {
const { html } = this.render(LoginFrom, {
global: {
stubs: {
GoogleLoginButton: this.stub('google-login-button')
}
}
GoogleLoginButton: this.stub('google-login-button'),
},
},
})
expect(html()).toMatchSnapshot()

View file

@ -51,9 +51,11 @@ import GoogleLoginButton from '@/components/auth/sso/GoogleLoginButton.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'loggedin'): void }>()
const DEMO_ACCOUNT = {
email: 'demo@koel.dev',
password: 'demo'
password: 'demo',
}
const canResetPassword = window.MAILER_CONFIGURED && !window.IS_DEMO
@ -66,8 +68,6 @@ const showingForgotPasswordForm = ref(false)
const showForgotPasswordForm = () => (showingForgotPasswordForm.value = true)
const emit = defineEmits<{ (e: 'loggedin'): void }>()
const login = async () => {
try {
await authService.login(email.value, password.value)
@ -103,10 +103,12 @@ const onSSOSuccess = (token: CompositeToken) => {
* You like to - move it!
*/
@keyframes shake {
8%, 41% {
8%,
41% {
transform: translateX(-10px);
}
25%, 58% {
25%,
58% {
transform: translateX(10px);
}
75% {
@ -115,7 +117,8 @@ const onSSOSuccess = (token: CompositeToken) => {
92% {
transform: translateX(5px);
}
0%, 100% {
0%,
100% {
transform: translateX(0);
}
}
@ -123,7 +126,7 @@ const onSSOSuccess = (token: CompositeToken) => {
form {
&.error {
@apply border-red-500;
animation: shake .5s;
animation: shake 0.5s;
}
}
</style>

View file

@ -12,7 +12,7 @@ new class extends UnitTestCase {
await this.router.activateRoute({
path: '_',
screen: 'Password.Reset'
screen: 'Password.Reset',
}, { payload: 'Zm9vQGJhci5jb218bXktdG9rZW4=' })
this.render(ResetPasswordForm)

View file

@ -20,7 +20,7 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { authService } from '@/services'
import { base64Decode } from '@/utils'
import { base64Decode, logger } from '@/utils'
import { useErrorHandler, useMessageToaster, useRouter } from '@/composables'
import PasswordField from '@/components/ui/form/PasswordField.vue'
@ -39,6 +39,7 @@ const validPayload = computed(() => email.value && token.value)
try {
[email.value, token.value] = base64Decode(decodeURIComponent(getRouteParam('payload')!)).split('|')
} catch (error: unknown) {
logger.error(error)
toastError('Invalid reset password link.')
}

View file

@ -12,15 +12,15 @@ new class extends UnitTestCase {
.mockResolvedValue(factory.states('prospect')('user'))
const acceptMock = this.mock(invitationService, 'accept').mockResolvedValue({
token: 'my-api-token',
'audio-token': 'my-audio-token'
'token': 'my-api-token',
'audio-token': 'my-audio-token',
})
await this.router.activateRoute({
path: '_',
screen: 'Invitation.Accept'
screen: 'Invitation.Accept',
}, {
token: 'my-token'
token: 'my-token',
})
this.render(AcceptInvitation)

View file

@ -12,6 +12,7 @@
<Btn :disabled="loading" class="!rounded-l-none" type="submit">Activate</Btn>
</form>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { plusService } from '@/services'

View file

@ -13,8 +13,8 @@ new class extends UnitTestCase {
screen.getByTestId('buttons')
expect(screen.queryByTestId('activateForm')).toBeNull()
await this.user.click(screen.getByText('Purchase Koel Plus'))
expect(global.LemonSqueezy.Url.Open).toHaveBeenCalledWith(
'https://store.koel.dev/checkout/buy/42?embed=1&media=0&desc=0'
expect(globalThis.LemonSqueezy.Url.Open).toHaveBeenCalledWith(
'https://store.koel.dev/checkout/buy/42?embed=1&media=0&desc=0',
)
})

View file

@ -42,9 +42,10 @@ import { useKoelPlus } from '@/composables'
import Btn from '@/components/ui/form/Btn.vue'
import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { checkoutUrl } = useKoelPlus()
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const showingActivateLicenseForm = ref(false)

View file

@ -2,7 +2,7 @@ import { it } from 'vitest'
import { screen, waitFor } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { Events } from '@/config'
import type { Events } from '@/config'
import UnitTestCase from '@/__tests__/UnitTestCase'
import ModalWrapper from './ModalWrapper.vue'
@ -23,7 +23,7 @@ new class extends UnitTestCase {
['about-koel', 'MODAL_SHOW_ABOUT_KOEL', undefined],
['koel-plus', 'MODAL_SHOW_KOEL_PLUS', undefined],
['equalizer', 'MODAL_SHOW_EQUALIZER', undefined],
['add-podcast-form', 'MODAL_SHOW_ADD_PODCAST_FORM', undefined]
['add-podcast-form', 'MODAL_SHOW_ADD_PODCAST_FORM', undefined],
])('shows %s modal', async (modalName, eventName, eventParams?: any) => {
this.render(ModalWrapper, {
global: {
@ -42,9 +42,9 @@ new class extends UnitTestCase {
Equalizer: this.stub('equalizer'),
InviteUserForm: this.stub('invite-user-form'),
KoelPlus: this.stub('koel-plus'),
PlaylistCollaborationModal: this.stub('playlist-collaboration')
}
}
PlaylistCollaborationModal: this.stub('playlist-collaboration'),
},
},
})
eventBus.emit(eventName, eventParams)

View file

@ -28,7 +28,7 @@ const modalNameToComponentMap = {
'equalizer': defineAsyncComponent(() => import('@/components/ui/equalizer/Equalizer.vue')),
'invite-user-form': defineAsyncComponent(() => import('@/components/user/InviteUserForm.vue')),
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue'))
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
}
type ModalName = keyof typeof modalNameToComponentMap
@ -53,7 +53,7 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, playables?) => {
context.value = {
folder,
playables: playables ? arrayify(playables) : []
playables: playables ? arrayify(playables) : [],
}
activeModalName.value = 'create-playlist-form'
@ -74,7 +74,7 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
.on('MODAL_SHOW_EDIT_SONG_FORM', (songs, initialTab: EditSongFormTabName = 'details') => {
context.value = {
initialTab,
songs: arrayify(songs)
songs: arrayify(songs),
}
activeModalName.value = 'edit-song-form'
@ -95,10 +95,13 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
<style lang="postcss" scoped>
dialog {
:deep(form), :deep(>div) {
:deep(form),
:deep(> div) {
@apply relative;
> header, > main, > footer {
> header,
> main,
> footer {
@apply px-6 py-5;
}

View file

@ -27,9 +27,9 @@ new class extends UnitTestCase {
global: {
stubs: {
Equalizer: this.stub('Equalizer'),
Volume: this.stub('Volume')
}
}
Volume: this.stub('Volume'),
},
},
})
}
}

View file

@ -34,7 +34,7 @@
<script lang="ts" setup>
import { faBolt, faCompress, faExpand, faSliders } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, ref } from 'vue'
import { eventBus, isAudioContextSupported as useEqualizer, isFullscreenSupported } from '@/utils'
import { eventBus, isFullscreenSupported, isAudioContextSupported as useEqualizer } from '@/utils'
import { useRouter } from '@/composables'
import VolumeSlider from '@/components/ui/VolumeSlider.vue'

View file

@ -40,19 +40,19 @@ new class extends UnitTestCase {
artist_id: 3,
album_name: 'Led Zeppelin IV',
album_id: 4,
liked: true
liked: true,
})
}
return this.render(Component, {
global: {
stubs: {
PlayButton: this.stub('PlayButton')
PlayButton: this.stub('PlayButton'),
},
provide: {
[<symbol>CurrentPlayableKey]: ref(playable)
}
}
[<symbol>CurrentPlayableKey]: ref(playable),
},
},
})
}
}

View file

@ -17,7 +17,7 @@ new class extends UnitTestCase {
it('goes back if current screen is Queue', async () => {
this.router.$currentRoute.value = {
screen: 'Queue',
path: '/queue'
path: '/queue',
}
const goMock = this.mock(Router, 'go')

View file

@ -26,7 +26,7 @@ const { go, isCurrentScreen } = useRouter()
const { toastWarning, toastSuccess } = useMessageToaster()
const { acceptsDrop, resolveDroppedItems } = useDroppable(
['playables', 'album', 'artist', 'playlist', 'playlist-folder']
['playables', 'album', 'artist', 'playlist', 'playlist-folder'],
)
const droppable = ref(false)

View file

@ -15,15 +15,15 @@ new class extends UnitTestCase {
album_cover: 'https://via.placeholder.com/150',
playback_state: 'Playing',
artist_id: 10,
artist_name: 'Led Zeppelin'
artist_name: 'Led Zeppelin',
})
expect(this.render(FooterSongInfo, {
global: {
provide: {
[<symbol>CurrentPlayableKey]: ref(song)
}
}
[<symbol>CurrentPlayableKey]: ref(song),
},
},
}).html()).toMatchSnapshot()
})
}

View file

@ -29,17 +29,23 @@ const { startDragging } = useDraggable('playables')
const song = requireInjection(CurrentPlayableKey, ref())
const cover = computed(() => {
if (!song.value) return defaultCover
if (!song.value) {
return defaultCover
}
return getPlayableProp(song.value, 'album_cover', 'episode_image')
})
const artistOrPodcastUri = computed(() => {
if (!song.value) return ''
if (!song.value) {
return ''
}
return isSong(song.value) ? `#/artist/${song.value?.artist_id}` : `#/podcasts/${song.value.podcast_id}`
})
const artistOrPodcastName = computed(() => {
if (!song.value) return ''
if (!song.value) {
return ''
}
return getPlayableProp(song.value, 'artist_name', 'podcast_title')
})

View file

@ -38,12 +38,16 @@ const root = ref<HTMLElement>()
const artist = ref<Artist>()
const requestContextMenu = (event: MouseEvent) => {
if (document.fullscreenElement) return
if (document.fullscreenElement) {
return
}
playable.value && eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', event, playable.value)
}
watch(playable, async () => {
if (!playable.value) return
if (!playable.value) {
return
}
if (isSong(playable.value)) {
artist.value = await artistStore.resolve(playable.value.artist_id)
@ -51,7 +55,9 @@ watch(playable, async () => {
})
const appBackgroundImage = computed(() => {
if (!playable.value || !isSong(playable.value)) return 'none'
if (!playable.value || !isSong(playable.value)) {
return 'none'
}
const src = artist.value?.image ?? playable.value.album_cover
return src ? `url(${src})` : 'none'
@ -71,7 +77,9 @@ const initPlaybackRelatedServices = async () => {
}
watch(preferenceStore.initialized, async initialized => {
if (!initialized) return
if (!initialized) {
return
}
await initPlaybackRelatedServices()
}, { immediate: true })
@ -80,7 +88,9 @@ const setupControlHidingTimer = () => {
}
const showControls = throttle(() => {
if (!document.fullscreenElement) return
if (!document.fullscreenElement) {
return
}
root.value?.classList.remove('hide-controls')
window.clearTimeout(hideControlsTimeout)
@ -103,7 +113,7 @@ eventBus.on('FULLSCREEN_TOGGLE', () => toggleFullscreen())
<style lang="postcss" scoped>
footer {
box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2);
box-shadow: 0 0 30px 20px rgba(0, 0, 0, 0.2);
.fullscreen-backdrop {
background-image: v-bind(appBackgroundImage);
@ -119,17 +129,20 @@ footer {
}
.wrapper {
@apply z-[3]
@apply z-[3];
}
&::before {
@apply bg-black bg-repeat absolute top-0 left-0 opacity-50 z-[1] pointer-events-none -m-[20rem];
content: '';
background-image: linear-gradient(135deg, #111 25%, transparent 25%),
linear-gradient(225deg, #111 25%, transparent 25%),
linear-gradient(45deg, #111 25%, transparent 25%),
linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%);
background-position: 6px 0, 6px 0, 0 0, 0 0;
linear-gradient(225deg, #111 25%, transparent 25%), linear-gradient(45deg, #111 25%, transparent 25%),
linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%);
background-position:
6px 0,
6px 0,
0 0,
0 0;
background-size: 6px 6px;
width: calc(100% + 40rem);
height: calc(100% + 40rem);

View file

@ -31,7 +31,7 @@ new class extends UnitTestCase {
return this.render(MainContent, {
global: {
provide: {
[<symbol>CurrentPlayableKey]: ref(factory('song'))
[<symbol>CurrentPlayableKey]: ref(factory('song')),
},
stubs: {
AlbumArtOverlay,
@ -45,9 +45,9 @@ new class extends UnitTestCase {
SearchExcerptsScreen: this.stub('search-excerpts-screen'),
GenreScreen: this.stub('genre-screen'),
HomeScreen: this.stub(), // so that home overview requests are not made
Visualizer: this.stub('visualizer')
}
}
Visualizer: this.stub('visualizer'),
},
},
})
}
}

View file

@ -30,7 +30,7 @@
<ArtistScreen v-if="screen === 'Artist'" />
<SettingsScreen v-if="screen === 'Settings'" />
<ProfileScreen v-if="screen === 'Profile'" />
<PodcastScreen v-if="screen ==='Podcast'" />
<PodcastScreen v-if="screen === 'Podcast'" />
<EpisodeScreen v-if="screen === 'Episode'" />
<UserListScreen v-if="screen === 'Users'" />
<YouTubeScreen v-if="useYouTube" v-show="screen === 'YouTube'" />

View file

@ -1,6 +1,9 @@
import { ref, Ref } from 'vue'
import { expect, it, Mock } from 'vitest'
import { RenderResult, screen, waitFor } from '@testing-library/vue'
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { Mock } from 'vitest'
import { expect, it } from 'vitest'
import type { RenderResult } from '@testing-library/vue'
import { screen, waitFor } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import { albumStore, artistStore, commonStore, preferenceStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
@ -80,12 +83,12 @@ new class extends UnitTestCase {
AlbumInfo: this.stub('album-info'),
ArtistInfo: this.stub('artist-info'),
YouTubeVideoList: this.stub('youtube-video-list'),
ExtraPanelTabHeader: this.stub()
ExtraPanelTabHeader: this.stub(),
},
provide: {
[<symbol>CurrentPlayableKey]: songRef
}
}
[<symbol>CurrentPlayableKey]: songRef,
},
},
})
return [rendered, resolveArtistMock, resolveAlbumMock]

View file

@ -73,7 +73,8 @@
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { faBars } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, onMounted, ref, Ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { albumStore, artistStore, preferenceStore } from '@/stores'
import { useErrorHandler, useThirdPartyServices } from '@/composables'
import { eventBus, isSong, requireInjection } from '@/utils'
@ -115,7 +116,9 @@ const fetchSongInfo = async (song: Song) => {
}
watch(playable, song => {
if (!song || !isSong(song)) return
if (!song || !isSong(song)) {
return
}
fetchSongInfo(song)
}, { immediate: true })
@ -134,7 +137,7 @@ onMounted(() => isMobile.any || (activeTab.value = preferenceStore.active_extra_
@layer utilities {
.btn-group {
@apply flex md:flex-col justify-between items-center gap-1 md:gap-3
@apply flex md:flex-col justify-between items-center gap-1 md:gap-3;
}
}

View file

@ -46,7 +46,7 @@ import { useThirdPartyServices } from '@/composables'
import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), {
modelValue: null
modelValue: null,
})
const emit = defineEmits<{ (e: 'update:modelValue', value: ExtraPanelTab | null): void }>()
@ -55,7 +55,7 @@ const { useYouTube } = useThirdPartyServices()
const value = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
set: value => emit('update:modelValue', value),
})
const toggleTab = (tab: ExtraPanelTab) => (value.value = value.value === tab ? null : tab)

View file

@ -67,7 +67,9 @@ const toggle = () => (opened.value = !opened.value)
const onDragStart = (event: DragEvent) => startDragging(event, folder.value)
const onDragOver = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
if (!acceptsDrop(event)) {
return false
}
event.preventDefault()
droppable.value = true
@ -79,12 +81,16 @@ const onDragLeave = () => (droppable.value = false)
const onDrop = async (event: DragEvent) => {
droppable.value = false
if (!acceptsDrop(event)) return false
if (!acceptsDrop(event)) {
return false
}
event.preventDefault()
const playlist = await resolveDroppedValue<Playlist>(event)
if (!playlist || playlist.folder_id === folder.value.id) return
if (!playlist || playlist.folder_id === folder.value.id) {
return
}
await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
}
@ -92,7 +98,9 @@ const onDrop = async (event: DragEvent) => {
const onDragLeaveHatch = () => (droppableOnHatch.value = false)
const onDragOverHatch = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
if (!acceptsDrop(event)) {
return false
}
event.preventDefault()
droppableOnHatch.value = true
@ -105,7 +113,9 @@ const onDropOnHatch = async (event: DragEvent) => {
const playlist = (await resolveDroppedValue<Playlist>(event))!
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.
if (playlist.folder_id !== folder.value.id) return
if (playlist.folder_id !== folder.value.id) {
return
}
// otherwise, the user is trying to remove the playlist from the folder.
event.stopPropagation()
@ -115,7 +125,7 @@ const onDropOnHatch = async (event: DragEvent) => {
const onContextMenu = (event: MouseEvent) => eventBus.emit(
'PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED',
event,
folder.value
folder.value,
)
</script>

View file

@ -9,8 +9,8 @@ new class extends UnitTestCase {
renderComponent (list: PlaylistLike) {
this.render(PlaylistSidebarItem, {
props: {
list
}
list,
},
})
}
@ -26,10 +26,10 @@ new class extends UnitTestCase {
})
it.each<FavoriteList['name'] | RecentlyPlayedList['name']>(['Favorites', 'Recently Played'])
('does not request context menu if not playlist', async (name) => {
('does not request context menu if not playlist', async name => { // eslint-disable-line no-unexpected-multiline
const list: FavoriteList | RecentlyPlayedList = {
name,
songs: []
songs: [],
}
const emitMock = this.mock(eventBus, 'emit')

View file

@ -31,6 +31,7 @@ import { useDraggable, useDroppable, usePlaylistManagement, useRouter } from '@/
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
const props = defineProps<{ list: PlaylistLike }>()
const { onRouteChanged } = useRouter()
const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist'])
@ -39,7 +40,6 @@ const droppable = ref(false)
const { addToPlaylist } = usePlaylistManagement()
const props = defineProps<{ list: PlaylistLike }>()
const { list } = toRefs(props)
const isPlaylist = (list: PlaylistLike): list is Playlist => 'id' in list
@ -49,16 +49,26 @@ const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList =>
const current = ref(false)
const url = computed(() => {
if (isPlaylist(list.value)) return `#/playlist/${list.value.id}`
if (isFavoriteList(list.value)) return '#/favorites'
if (isRecentlyPlayedList(list.value)) return '#/recently-played'
if (isPlaylist(list.value)) {
return `#/playlist/${list.value.id}`
}
if (isFavoriteList(list.value)) {
return '#/favorites'
}
if (isRecentlyPlayedList(list.value)) {
return '#/recently-played'
}
throw new Error('Invalid playlist-like type.')
})
const contentEditable = computed(() => {
if (isRecentlyPlayedList(list.value)) return false
if (isFavoriteList(list.value)) return true
if (isRecentlyPlayedList(list.value)) {
return false
}
if (isFavoriteList(list.value)) {
return true
}
return !list.value.is_smart
})
@ -73,8 +83,12 @@ const onContextMenu = (event: MouseEvent) => {
const onDragStart = (event: DragEvent) => isPlaylist(list.value) && startDragging(event, list.value)
const onDragOver = (event: DragEvent) => {
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
if (!contentEditable.value) {
return false
}
if (!acceptsDrop(event)) {
return false
}
event.preventDefault()
droppable.value = true
@ -87,12 +101,18 @@ const onDragLeave = () => (droppable.value = false)
const onDrop = async (event: DragEvent) => {
droppable.value = false
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
if (!contentEditable.value) {
return false
}
if (!acceptsDrop(event)) {
return false
}
const playables = await resolveDroppedItems(event)
if (!playables?.length) return false
if (!playables?.length) {
return false
}
if (isFavoriteList(list.value)) {
await favoriteStore.like(playables)

View file

@ -11,7 +11,7 @@ const standardItems = [
'Artists',
'Genres',
'Favorites',
'Recently Played'
'Recently Played',
]
const adminItems = [...standardItems, 'Users', 'Upload', 'Settings']

View file

@ -1,6 +1,6 @@
<template>
<nav
:class="{ collapsed: !expanded, 'tmp-showing': tmpShowing, showing: mobileShowing }"
:class="{ 'collapsed': !expanded, 'tmp-showing': tmpShowing, 'showing': mobileShowing }"
class="group left-0 top-0 flex flex-col fixed h-full w-full md:relative md:w-k-sidebar-width z-[999] md:z-10"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@ -29,8 +29,8 @@
</section>
<SidebarToggleButton
class="opacity-0 no-hover:hidden group-hover:opacity-100 transition"
v-model="expanded"
class="opacity-0 no-hover:hidden group-hover:opacity-100 transition"
:class="expanded || 'opacity-100'"
/>
</nav>
@ -68,10 +68,14 @@ let tmpShowingHandler: number | undefined
const tmpShowing = ref(false)
const onMouseEnter = () => {
if (expanded.value) return;
if (expanded.value) {
return
}
tmpShowingHandler = window.setTimeout(() => {
if (expanded.value) return
if (expanded.value) {
return
}
tmpShowing.value = true
}, 500)
}
@ -132,7 +136,7 @@ nav {
@mixin themed-background;
transform: translateX(-100vw);
transition: transform .2s ease-in-out;
transition: transform 0.2s ease-in-out;
&.showing {
transform: translateX(0);

View file

@ -14,7 +14,7 @@ new class extends UnitTestCase {
await this.router.activateRoute({
screen: 'Home',
path: '_'
path: '_',
})
expect(screen.getByTestId('sidebar-item').classList.contains('current')).toBe(true)
@ -34,11 +34,11 @@ new class extends UnitTestCase {
props: {
icon: faHome,
href: '#',
screen: 'Home'
screen: 'Home',
},
slots: {
default: 'Home'
}
default: 'Home',
},
})
}
}

View file

@ -27,9 +27,9 @@ import { ref } from 'vue'
import { useRouter } from '@/composables'
import { eventBus } from '@/utils'
const props = withDefaults(defineProps<{ href?: string | undefined; screen?: ScreenName | undefined }>(), {
const props = withDefaults(defineProps<{ href?: string | undefined, screen?: ScreenName | undefined }>(), {
href: undefined,
screen: undefined
screen: undefined,
})
const current = ref(false)

View file

@ -26,6 +26,7 @@
</ul>
</SidebarSection>
</template>
<script lang="ts" setup>
import { faTools, faUpload, faUsers } from '@fortawesome/free-solid-svg-icons'
import { useAuthorization, useUpload } from '@/composables'

View file

@ -13,7 +13,7 @@ new class extends UnitTestCase {
playlistStore.state.playlists = [
factory.states('orphan')('playlist', { name: 'Foo Playlist' }),
factory.states('orphan')('playlist', { name: 'Bar Playlist' }),
factory.states('smart', 'orphan')('playlist', { name: 'Smart Playlist' })
factory.states('smart', 'orphan')('playlist', { name: 'Smart Playlist' }),
]
this.renderComponent()
@ -26,7 +26,7 @@ new class extends UnitTestCase {
it('displays playlist folders', () => {
playlistFolderStore.state.folders = [
factory('playlist-folder', { name: 'Foo Folder' }),
factory('playlist-folder', { name: 'Bar Folder' })
factory('playlist-folder', { name: 'Bar Folder' }),
]
this.renderComponent()
@ -39,9 +39,9 @@ new class extends UnitTestCase {
global: {
stubs: {
PlaylistSidebarItem,
PlaylistFolderSidebarItem
}
}
PlaylistFolderSidebarItem,
},
},
})
}
}

View file

@ -29,7 +29,9 @@ const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'playables')
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
if (folder_id === null) return true
if (folder_id === null) {
return true
}
// if the playlist's folder is not found, it's an orphan
// this can happen if the playlist belongs to another user (collaborative playlist)

View file

@ -18,6 +18,6 @@ const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const value = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
set: value => emit('update:modelValue', value),
})
</script>

View file

@ -39,9 +39,9 @@ new class extends UnitTestCase {
return this.render(AboutKoelModel, {
global: {
stubs: {
SponsorList: this.stub('sponsor-list')
}
}
SponsorList: this.stub('sponsor-list'),
},
},
})
}
}

View file

@ -41,8 +41,8 @@
<a href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a>
and quite a few
<a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a>&nbsp;<a
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
>contributors</a>.
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
>contributors</a>.
</p>
<CreditsBlock v-if="isDemo" />
@ -71,17 +71,18 @@ import Btn from '@/components/ui/form/Btn.vue'
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
import CreditsBlock from '@/components/meta/CreditsBlock.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const {
shouldNotifyNewVersion,
currentVersion,
latestVersion,
latestVersionReleaseUrl
latestVersionReleaseUrl,
} = useNewVersionNotification()
const { isPlus, license } = useKoelPlus()
const { isAdmin } = useAuthorization()
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const showPlusModal = () => {
@ -89,7 +90,7 @@ const showPlusModal = () => {
eventBus.emit('MODAL_SHOW_KOEL_PLUS')
}
const isDemo = window.IS_DEMO;
const isDemo = window.IS_DEMO
</script>
<style lang="postcss" scoped>

View file

@ -11,7 +11,7 @@ new class extends UnitTestCase {
const getMock = this.mock(http, 'get').mockResolvedValue([
{ name: 'Foo', url: 'https://foo.com' },
{ name: 'Bar', url: 'https://bar.com' },
{ name: 'Something Else', url: 'https://something-else.net' }
{ name: 'Something Else', url: 'https://something-else.net' },
])
const { html } = this.render(CreditsBlock)

View file

@ -14,7 +14,7 @@ import { orderBy } from 'lodash'
import { onMounted, ref } from 'vue'
import { http } from '@/services'
type DemoCredits = {
interface DemoCredits {
name: string
url: string
}
@ -29,7 +29,7 @@ onMounted(async () => {
<style lang="postcss" scoped>
li&:last-child {
&::before {
content: ', and '
content: ', and ';
}
&::after {

View file

@ -9,7 +9,7 @@ new class extends UnitTestCase {
protected beforeEach () {
// Prevent actual HTTP requests from being made
this.setReadOnlyProperty(http, 'silently', {
patch: vi.fn()
patch: vi.fn(),
})
super.beforeEach(() => vi.useFakeTimers())

View file

@ -38,9 +38,15 @@ const stopBugging = () => {
}
watch(preferenceStore.initialized, initialized => {
if (!initialized) return
if (preferenceStore.state.support_bar_no_bugging || isMobile.any) return
if (isPlus.value) return
if (!initialized) {
return
}
if (preferenceStore.state.support_bar_no_bugging || isMobile.any) {
return
}
if (isPlus.value) {
return
}
setUpShowBarTimeout()
}, { immediate: true })

View file

@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
import { screen, waitFor } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import { Events } from '@/config'
import type { Events } from '@/config'
import CreateNewPlaylistContextMenu from './CreatePlaylistContextMenu.vue'
@ -11,7 +11,7 @@ new class extends UnitTestCase {
it.each<[string, keyof Events]>([
['playlist-context-menu-create-simple', 'MODAL_SHOW_CREATE_PLAYLIST_FORM'],
['playlist-context-menu-create-smart', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'],
['playlist-context-menu-create-folder', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM']
['playlist-context-menu-create-folder', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'],
])('when clicking on %s, should emit %s', async (id, eventName) => {
await this.renderComponent()
const emitMock = this.mock(eventBus, 'emit')

View file

@ -11,7 +11,7 @@
<script lang="ts" setup>
import { useContextMenu } from '@/composables'
import { eventBus } from '@/utils'
import { Events } from '@/config'
import type { Events } from '@/config'
const { base, ContextMenu, open, trigger } = useContextMenu()
@ -20,7 +20,7 @@ type Action = 'new-playlist' | 'new-smart-playlist' | 'new-folder'
const actionToEventMap: Record<Action, keyof Events> = {
'new-playlist': 'MODAL_SHOW_CREATE_PLAYLIST_FORM',
'new-smart-playlist': 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM',
'new-folder': 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'
'new-folder': 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM',
}
const onItemClicked = (key: keyof typeof actionToEventMap) => trigger(() => eventBus.emit(actionToEventMap[key]))

View file

@ -18,7 +18,7 @@ const requestContextMenu = (e: MouseEvent) => {
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', {
top: bottom,
left: right
left: right,
})
}
</script>

View file

@ -32,13 +32,13 @@ import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
const name = ref('')
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const submit = async () => {

View file

@ -16,9 +16,9 @@ new class extends UnitTestCase {
this.render(CreatePlaylistForm, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ folder })]
}
}
[<symbol>ModalContextKey]: [ref({ folder })],
},
},
})
expect(screen.queryByTestId('from-playables')).toBeNull()
@ -27,7 +27,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByRole('button', { name: 'Save' }))
expect(storeMock).toHaveBeenCalledWith('My playlist', {
folder_id: folder.id
folder_id: folder.id,
}, [])
})
@ -39,9 +39,9 @@ new class extends UnitTestCase {
this.render(CreatePlaylistForm, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ folder, playables })]
}
}
[<symbol>ModalContextKey]: [ref({ folder, playables })],
},
},
})
screen.getByText('from 3 songs')
@ -50,7 +50,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByRole('button', { name: 'Save' }))
expect(storeMock).toHaveBeenCalledWith('My playlist', {
folder_id: folder.id
folder_id: folder.id,
}, playables)
})
}

View file

@ -43,6 +43,7 @@ import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
@ -56,7 +57,6 @@ const folderId = ref(targetFolder?.id)
const name = ref('')
const folders = toRef(playlistFolderStore.state, 'folders')
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const noun = computed(() => {
@ -75,7 +75,7 @@ const submit = async () => {
try {
const playlist = await playlistStore.store(name.value, {
folder_id: folderId.value
folder_id: folderId.value,
}, playables)
close()

View file

@ -15,9 +15,9 @@ new class extends UnitTestCase {
this.render(EditPlaylistFolderForm, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ folder })]
}
}
[<symbol>ModalContextKey]: [ref({ folder })],
},
},
})
await this.type(screen.getByPlaceholderText('Folder name'), 'Your folder')

View file

@ -26,6 +26,7 @@ import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
@ -33,6 +34,8 @@ const folder = useModal().getFromContext<PlaylistFolder>('folder')
const name = ref(folder.name)
const close = () => emit('close')
const submit = async () => {
showOverlay()
@ -47,9 +50,6 @@ const submit = async () => {
}
}
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {
if (name.value.trim() === folder.name) {
close()

View file

@ -14,7 +14,7 @@ new class extends UnitTestCase {
const playlist = factory('playlist', {
name: 'My playlist',
folder_id: playlistFolderStore.state.folders[0].id
folder_id: playlistFolderStore.state.folders[0].id,
})
playlistStore.state.playlists = [playlist]
@ -24,9 +24,9 @@ new class extends UnitTestCase {
this.render(EditPlaylistForm, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ playlist })]
}
}
[<symbol>ModalContextKey]: [ref({ playlist })],
},
},
})
await this.type(screen.getByPlaceholderText('Playlist name'), 'Your playlist')
@ -35,7 +35,7 @@ new class extends UnitTestCase {
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(playlist, {
name: 'Your playlist',
folder_id: playlist.folder_id
folder_id: playlist.folder_id,
})
})
})

View file

@ -44,6 +44,7 @@ import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
@ -53,7 +54,6 @@ const name = ref(playlist.name)
const folderId = ref(playlist.folder_id)
const folders = toRef(playlistFolderStore.state, 'folders')
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const submit = async () => {
@ -62,7 +62,7 @@ const submit = async () => {
try {
await playlistStore.update(playlist, {
name: name.value,
folder_id: folderId.value
folder_id: folderId.value,
})
toastSuccess('Playlist updated.')
@ -92,6 +92,6 @@ form {
}
label.folder {
flex: .6;
flex: 0.6;
}
</style>

View file

@ -13,8 +13,8 @@ new class extends UnitTestCase {
this.render(Component, {
props: {
playlist
}
playlist,
},
})
await this.user.click(screen.getByText('Invite'))

View file

@ -11,13 +11,13 @@ new class extends UnitTestCase {
const { html } = this.render(Modal, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ playlist: factory('playlist') })]
[<symbol>ModalContextKey]: [ref({ playlist: factory('playlist') })],
},
stubs: {
InviteCollaborators: this.stub('InviteCollaborators'),
CollaboratorList: this.stub('CollaboratorList')
}
}
CollaboratorList: this.stub('CollaboratorList'),
},
},
})
expect(html()).toMatchSnapshot()

View file

@ -2,8 +2,8 @@
<div
class="collaboration-modal max-w-[640px]"
tabindex="0"
@keydown.esc="close"
data-testid="playlist-collaboration"
@keydown.esc="close"
>
<header>
<h1>Playlist Collaboration</h1>
@ -41,12 +41,12 @@ import Btn from '@/components/ui/form/Btn.vue'
import InviteCollaborators from '@/components/playlist/InvitePlaylistCollaborators.vue'
import CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const playlist = useModal().getFromContext<Playlist>('playlist')
const { currentUser } = useAuthorization()
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
</script>

View file

@ -8,11 +8,11 @@ new class extends UnitTestCase {
protected test () {
it('renders', async () => {
const playlist = factory('playlist', {
is_collaborative: true
is_collaborative: true,
})
const fetchMock = this.mock(playlistCollaborationService, 'fetchCollaborators').mockResolvedValue(
factory('playlist-collaborator', 5)
factory('playlist-collaborator', 5),
)
const { html } = await this.be().renderComponent(playlist)
@ -24,13 +24,13 @@ new class extends UnitTestCase {
private async renderComponent (playlist: Playlist) {
const rendered = this.render(Component, {
props: {
playlist
playlist,
},
global: {
stubs: {
ListItem: this.stub('ListItem')
}
}
ListItem: this.stub('ListItem'),
},
},
})
await this.tick(2)

View file

@ -15,7 +15,8 @@
<script lang="ts" setup>
import { sortBy } from 'lodash'
import { computed, onMounted, ref, Ref, toRefs } from 'vue'
import type { Ref } from 'vue'
import { computed, onMounted, ref, toRefs } from 'vue'
import { useAuthorization, useDialogBox, useErrorHandler } from '@/composables'
import { playlistCollaborationService } from '@/services'
import { eventBus } from '@/utils'
@ -29,7 +30,7 @@ const { playlist } = toRefs(props)
const { currentUser } = useAuthorization()
const { showConfirmDialog } = useDialogBox()
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
const collaborators: Ref<PlaylistCollaborator[]> = ref([])
const loading = ref(false)
const currentUserIsOwner = computed(() => currentUser.value?.id === playlist.value.user_id)
@ -41,10 +42,14 @@ const fetchCollaborators = async () => {
collaborators.value = sortBy(
await playlistCollaborationService.fetchCollaborators(playlist.value),
({ id }) => {
if (id === currentUser.value.id) return 0
if (id === playlist.value.user_id) return 1
if (id === currentUser.value.id) {
return 0
}
if (id === playlist.value.user_id) {
return 1
}
return 2
}
},
)
} finally {
loading.value = false
@ -53,10 +58,12 @@ const fetchCollaborators = async () => {
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
const deadSure = await showConfirmDialog(
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`,
)
if (!deadSure) return
if (!deadSure) {
return
}
try {
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)

View file

@ -12,7 +12,7 @@ new class extends UnitTestCase {
collaborator: factory('playlist-collaborator', { id: currentUser.id + 1 }),
removable: true,
manageable: true,
role: 'owner'
role: 'owner',
})
expect(screen.queryByTitle('This is you!')).toBeNull()
@ -21,16 +21,14 @@ new class extends UnitTestCase {
it('shows a badge when current user is the collaborator', async () => {
const currentUser = factory('user')
this.be(currentUser).renderComponent({
collaborator: factory('playlist-collaborator',
{
id: currentUser.id,
name: currentUser.name,
avatar: currentUser.avatar
}
),
collaborator: factory('playlist-collaborator', {
id: currentUser.id,
name: currentUser.name,
avatar: currentUser.avatar,
}),
removable: true,
manageable: true,
role: 'owner'
role: 'owner',
})
screen.getByTitle('This is you!')
@ -43,7 +41,7 @@ new class extends UnitTestCase {
collaborator,
removable: true,
manageable: true,
role: 'owner'
role: 'owner',
})
screen.getByText('Owner')
@ -52,7 +50,7 @@ new class extends UnitTestCase {
collaborator,
removable: true,
manageable: true,
role: 'contributor'
role: 'contributor',
})
screen.getByText('Contributor')
@ -64,7 +62,7 @@ new class extends UnitTestCase {
collaborator,
removable: true,
manageable: true,
role: 'owner'
role: 'owner',
})
await this.user.click(screen.getByRole('button', { name: 'Remove' }))
@ -74,18 +72,18 @@ new class extends UnitTestCase {
}
private renderComponent (props: {
collaborator: PlaylistCollaborator,
removable: boolean,
manageable: boolean,
collaborator: PlaylistCollaborator
removable: boolean
manageable: boolean
role: 'owner' | 'contributor'
}) {
return this.render(Component, {
props,
global: {
stubs: {
UserAvatar: this.stub('UserAvatar')
}
}
UserAvatar: this.stub('UserAvatar'),
},
},
})
}
}

View file

@ -34,16 +34,15 @@ import UserAvatar from '@/components/user/UserAvatar.vue'
import { useAuthorization } from '@/composables'
const props = defineProps<{
collaborator: PlaylistCollaborator,
removable: boolean,
manageable: boolean,
collaborator: PlaylistCollaborator
removable: boolean
manageable: boolean
role: 'owner' | 'contributor'
}>()
const emit = defineEmits<{ (e: 'remove'): void }>()
const { collaborator, removable, role } = toRefs(props)
const { currentUser } = useAuthorization()
const emit = defineEmits<{ (e: 'remove'): void }>()
</script>
<style lang="postcss" scoped>

View file

@ -133,7 +133,7 @@ new class extends UnitTestCase {
it('does not have an option to edit or delete if the playlist is not owned by the current user', async () => {
const user = factory('user')
const playlist = factory('playlist', {
user_id: user.id + 1
user_id: user.id + 1,
})
await this.renderComponent(playlist, user)
@ -156,7 +156,7 @@ new class extends UnitTestCase {
private async renderComponent (playlist: Playlist, user: User | null = null) {
userStore.state.current = user || factory('user', {
id: playlist.user_id
id: playlist.user_id,
})
this.render(PlaylistContextMenu)

View file

@ -62,7 +62,7 @@ import {
useModal,
useOverlay,
useRouter,
useSmartPlaylistForm
useSmartPlaylistForm,
} from '@/composables'
import CheckBox from '@/components/ui/form/CheckBox.vue'
@ -70,13 +70,15 @@ import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const {
Btn,
FormBase,
RuleGroup,
collectedRuleGroups,
addGroup,
onGroupChanged
onGroupChanged,
} = useSmartPlaylistForm()
const { showOverlay, hideOverlay } = useOverlay()
@ -92,7 +94,6 @@ const folderId = ref(targetFolder?.id)
const folders = toRef(playlistFolderStore.state, 'folders')
const ownSongsOnly = ref(false)
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const isPristine = () => name.value === ''
@ -115,7 +116,7 @@ const submit = async () => {
const playlist = await playlistStore.store(name.value, {
rules: collectedRuleGroups.value,
folder_id: folderId.value,
own_songs_only: ownSongsOnly.value
own_songs_only: ownSongsOnly.value,
})
close()

View file

@ -68,13 +68,14 @@ import {
useMessageToaster,
useModal,
useOverlay,
useSmartPlaylistForm
useSmartPlaylistForm,
} from '@/composables'
import CheckBox from '@/components/ui/form/CheckBox.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
@ -94,10 +95,9 @@ const {
RuleGroup,
collectedRuleGroups,
addGroup,
onGroupChanged
onGroupChanged,
} = useSmartPlaylistForm(mutablePlaylist.rules)
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -47,9 +47,15 @@ import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
import Btn from '@/components/ui/form/Btn.vue'
const props = defineProps<{ rule: SmartPlaylistRule }>()
const emit = defineEmits<{
(e: 'input', rule: SmartPlaylistRule): void
(e: 'remove'): void
}>()
const RuleInput = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/SmartPlaylistRuleInput.vue'))
const props = defineProps<{ rule: SmartPlaylistRule }>()
const { rule } = toRefs(props)
const mutatedRule = Object.assign({}, rule.value) as SmartPlaylistRule
@ -78,8 +84,8 @@ if (!operator) {
selectedOperator.value = operator
const isOriginalOperatorSelected = computed(() => {
return selectedModel.value?.name === mutatedRule.model.name &&
selectedOperator.value?.operator === mutatedRule.operator
return selectedModel.value?.name === mutatedRule.model.name
&& selectedOperator.value?.operator === mutatedRule.operator
})
const availableInputs = computed<{ id: string, value: any }[]>(() => {
@ -92,7 +98,7 @@ const availableInputs = computed<{ id: string, value: any }[]>(() => {
for (let i = 0, inputCount = selectedOperator.value.inputs || 1; i < inputCount; ++i) {
inputs.push({
id: `${mutatedRule.model.name}_${selectedOperator.value.operator}_${i}`,
value: isOriginalOperatorSelected.value ? mutatedRule.value[i] : ''
value: isOriginalOperatorSelected.value ? mutatedRule.value[i] : '',
})
}
@ -109,17 +115,12 @@ watch(availableOperators, () => {
const valueSuffix = computed(() => selectedOperator.value?.unit || selectedModel.value?.unit)
const emit = defineEmits<{
(e: 'input', rule: SmartPlaylistRule): void,
(e: 'remove'): void
}>()
const onInput = () => {
emit('input', {
id: mutatedRule.id,
model: selectedModel.value!,
operator: selectedOperator.value?.operator!,
value: availableInputs.value.map(input => input.value)
operator: selectedOperator.value!.operator,
value: availableInputs.value.map(input => input.value),
})
}

Some files were not shown because too many files have changed in this diff Show more