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

View file

@ -1,6 +1,7 @@
import isMobile from 'ismobilejs' import isMobile from 'ismobilejs'
import { isObject, mergeWith } from 'lodash' 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 { afterEach, beforeEach, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue' import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores' import { commonStore, userStore } from '@/stores'
@ -11,8 +12,8 @@ import { DialogBoxStub, MessageToasterStub, OverlayStub } from '@/__tests__/stub
import { routes } from '@/config' import { routes } from '@/config'
import Router from '@/router' import Router from '@/router'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup' import type { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'
import { EventType } from '@testing-library/dom/types/events' import type { EventType } from '@testing-library/dom/types/events'
// A deep-merge function that // A deep-merge function that
// - supports symbols as keys (_.merge doesn't) // - 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 // Credit: https://stackoverflow.com/a/60598589/794641
const deepMerge = (first: object, second: object) => { const deepMerge = (first: object, second: object) => {
return mergeWith(first, second, (a, b) => { return mergeWith(first, second, (a, b) => {
if (!isObject(b)) return b if (!isObject(b)) {
return b
}
// @ts-ignore // @ts-ignore
return Array.isArray(a) ? [...a, ...b] : { ...a, ...b } 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) => { const setPropIfNotExists = (obj: object | null, prop: any, value: any) => {
if (!obj) return if (!obj) {
return
}
if (!Object.prototype.hasOwnProperty.call(obj, prop)) { if (!Object.prototype.hasOwnProperty.call(obj, prop)) {
obj[prop] = value 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.user = userEvent.setup({ delay: null }) // @see https://github.com/testing-library/user-event/issues/833
this.setReadOnlyProperty(navigator, 'clipboard', { this.setReadOnlyProperty(navigator, 'clipboard', {
writeText: vi.fn() writeText: vi.fn(),
}) })
this.beforeEach() this.beforeEach()
@ -113,12 +118,12 @@ export default abstract class UnitTestCase {
'koel-focus': {}, 'koel-focus': {},
'koel-tooltip': {}, 'koel-tooltip': {},
'koel-hide-broken-icon': {}, 'koel-hide-broken-icon': {},
'koel-overflow-fade': {} 'koel-overflow-fade': {},
}, },
components: { components: {
Icon: this.stub('Icon') Icon: this.stub('Icon'),
} },
} },
}, this.supplyRequiredProvides(options))) }, this.supplyRequiredProvides(options)))
} }
@ -148,7 +153,7 @@ export default abstract class UnitTestCase {
protected stub (testId = 'stub') { protected stub (testId = 'stub') {
return defineComponent({ 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, { return Object.defineProperties(obj, {
[prop]: { [prop]: {
value, value,
configurable: true configurable: true,
} },
}) })
} }
@ -172,7 +177,7 @@ export default abstract class UnitTestCase {
await this.user.type(element, value) 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)) 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 => { export default (faker: Faker): Album => {
return { return {
@ -8,7 +8,7 @@ export default (faker: Faker): Album => {
id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default
name: faker.lorem.sentence(), name: faker.lorem.sentence(),
cover: faker.image.imageUrl(), 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, id: 1,
name: 'Unknown Album', name: 'Unknown Album',
artist_id: 1, 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' import factory from 'factoria'
export default (faker: Faker): AlbumInfo => ({ export default (faker: Faker): AlbumInfo => ({
cover: faker.image.imageUrl(), cover: faker.image.imageUrl(),
wiki: { wiki: {
summary: faker.lorem.sentence(), summary: faker.lorem.sentence(),
full: faker.lorem.sentences(4) full: faker.lorem.sentences(4),
}, },
tracks: factory('album-track', 8), 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 => ({ export default (faker: Faker): AlbumTrack => ({
title: faker.lorem.sentence(), 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 => { export default (faker: Faker): Artist => {
return { return {
@ -6,17 +6,17 @@ export default (faker: Faker): Artist => {
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
name: faker.name.findName(), name: faker.name.findName(),
image: 'foo.jpg', image: 'foo.jpg',
created_at: faker.date.past().toISOString() created_at: faker.date.past().toISOString(),
} }
} }
export const states: Record<string, Omit<Partial<Artist>, 'type'>> = { export const states: Record<string, Omit<Partial<Artist>, 'type'>> = {
unknown: { unknown: {
id: 1, id: 1,
name: 'Unknown Artist' name: 'Unknown Artist',
}, },
various: { various: {
id: 2, 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 => ({ export default (faker: Faker): ArtistInfo => ({
image: faker.image.imageUrl(), image: faker.image.imageUrl(),
bio: { bio: {
summary: faker.lorem.sentence(), 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 => { export default (faker: Faker): Episode => {
return { return {
@ -15,6 +15,6 @@ export default (faker: Faker): Episode => {
episode_image: faker.image.imageUrl(), episode_image: faker.image.imageUrl(),
podcast_id: faker.datatype.uuid(), podcast_id: faker.datatype.uuid(),
podcast_title: faker.lorem.sentence(), 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' import { genres } from '@/config'
export default (faker: Faker): Genre => { export default (faker: Faker): Genre => {
@ -6,6 +6,6 @@ export default (faker: Faker): Genre => {
type: 'genres', type: 'genres',
name: faker.helpers.arrayElement(genres), name: faker.helpers.arrayElement(genres),
song_count: faker.datatype.number({ min: 1, max: 1_000 }), 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 artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory'
import songFactory, { states as songStates } from '@/__tests__/factory/songFactory' import songFactory, { states as songStates } from '@/__tests__/factory/songFactory'
import albumFactory, { states as albumStates } from '@/__tests__/factory/albumFactory' 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 playlistCollaboratorFactory from '@/__tests__/factory/playlistCollaboratorFactory'
import episodeFactory from '@/__tests__/factory/episodeFactory' import episodeFactory from '@/__tests__/factory/episodeFactory'
import podcastFactory from '@/__tests__/factory/podcastFactory' import podcastFactory from '@/__tests__/factory/podcastFactory'
import { Faker } from '@faker-js/faker' import type { Faker } from '@faker-js/faker'
type ModelToTypeMap = { interface ModelToTypeMap {
artist: Artist 'artist': Artist
'artist-info': ArtistInfo 'artist-info': ArtistInfo
album: Album 'album': Album
'album-track': AlbumTrack 'album-track': AlbumTrack
'album-info': AlbumInfo 'album-info': AlbumInfo
song: Song 'song': Song
interaction: Interaction 'interaction': Interaction
genre: Genre 'genre': Genre
video: YouTubeVideo 'video': YouTubeVideo
'smart-playlist-rule': SmartPlaylistRule 'smart-playlist-rule': SmartPlaylistRule
'smart-playlist-rule-group': SmartPlaylistRuleGroup 'smart-playlist-rule-group': SmartPlaylistRuleGroup
playlist: Playlist 'playlist': Playlist
'playlist-folder': PlaylistFolder 'playlist-folder': PlaylistFolder
user: User 'user': User
'playlist-collaborator': PlaylistCollaborator 'playlist-collaborator': PlaylistCollaborator
episode: Episode 'episode': Episode
podcast: Podcast 'podcast': Podcast
} }
type Model = keyof ModelToTypeMap type Model = keyof ModelToTypeMap
@ -44,30 +45,30 @@ type Overrides<M extends Model> = Factoria.Overrides<ModelToTypeMap[M]>
const define = <M extends Model>( const define = <M extends Model>(
model: M, model: M,
handle: (faker: Faker) => Overrides<M>, handle: (faker: Faker) => Overrides<M>,
states?: Record<string, Factoria.StateDefinition> states?: Record<string, Factoria.StateDefinition>,
) => factoria.define(model, handle, states) ) => factoria.define(model, handle, states)
function factory <M extends Model>( function factory<M extends Model> (
model: M, model: M,
overrides?: Overrides<M> overrides?: Overrides<M>
): ModelToTypeMap[M] ): ModelToTypeMap[M]
function factory <M extends Model>( function factory<M extends Model> (
model: M, model: M,
count: 1, count: 1,
overrides?: Overrides<M> overrides?: Overrides<M>
): ModelToTypeMap[M] ): ModelToTypeMap[M]
function factory <M extends Model>( function factory<M extends Model> (
model: M, model: M,
count: number, count: number,
overrides?: Overrides<M> overrides?: Overrides<M>
): ModelToTypeMap[M][] ): ModelToTypeMap[M][]
function factory <M extends Model>( function factory<M extends Model> (
model: M, model: M,
count: number|Overrides<M> = 1, count: number | Overrides<M> = 1,
overrides?: Overrides<M> overrides?: Overrides<M>,
) { ) {
return typeof count === 'number' return typeof count === 'number'
? count === 1 ? factoria(model, overrides) : factoria(model, count, overrides) ? 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 => ({ export default (faker: Faker): Interaction => ({
type: 'interactions', type: 'interactions',
id: faker.datatype.number({ min: 1 }), id: faker.datatype.number({ min: 1 }),
song_id: faker.datatype.uuid(), song_id: faker.datatype.uuid(),
liked: faker.datatype.boolean(), 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 => ({ export default (faker: Faker): PlaylistCollaborator => ({
type: 'playlist-collaborators', type: 'playlist-collaborators',
id: faker.datatype.number(), id: faker.datatype.number(),
name: faker.name.findName(), 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 factory from 'factoria'
import { Faker } from '@faker-js/faker' import type { Faker } from '@faker-js/faker'
export default (faker: Faker): Playlist => ({ export default (faker: Faker): Playlist => ({
type: 'playlists', type: 'playlists',
@ -18,10 +18,10 @@ export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 't
smart: _ => ({ smart: _ => ({
is_smart: true, is_smart: true,
rules: [ rules: [
factory('smart-playlist-rule-group') factory('smart-playlist-rule-group'),
] ],
}), }),
orphan: _ => ({ 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 => ({ export default (faker: Faker): PlaylistFolder => ({
type: 'playlist-folders', type: 'playlist-folders',
id: faker.datatype.uuid(), 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 => { export default (faker: Faker): Podcast => {
return { return {
@ -14,7 +14,7 @@ export default (faker: Faker): Podcast => {
created_at: faker.date.past().toISOString(), created_at: faker.date.past().toISOString(),
state: { state: {
current_episode: null, 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' import models from '@/config/smart-playlist/models'
export default (faker: Faker): SmartPlaylistRule => ({ export default (faker: Faker): SmartPlaylistRule => ({
id: faker.datatype.uuid(), id: faker.datatype.uuid(),
model: faker.helpers.arrayElement(models), model: faker.helpers.arrayElement(models),
operator: faker.helpers.arrayElement<SmartPlaylistOperator['operator']>(['is', 'contains', 'isNot']), 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' import factory from 'factoria'
export default (faker: Faker): SmartPlaylistRuleGroup => ({ export default (faker: Faker): SmartPlaylistRuleGroup => ({
id: faker.datatype.uuid(), 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(), liked: faker.datatype.boolean(),
is_public: faker.datatype.boolean(), is_public: faker.datatype.boolean(),
created_at: faker.date.past().toISOString(), created_at: faker.date.past().toISOString(),
playback_state: 'Stopped' playback_state: 'Stopped',
} }
} }
export default (): Song => generate() export default (): Song => generate()
export const states: Record<string, Partial<Song>> = { 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 => ({ export default (faker: Faker): User => ({
type: 'users', type: 'users',
@ -11,14 +11,14 @@ export default (faker: Faker): User => ({
avatar: 'https://gravatar.com/foo', avatar: 'https://gravatar.com/foo',
preferences: undefined, preferences: undefined,
sso_provider: null, sso_provider: null,
sso_id: null sso_id: null,
}) })
export const states: Record<string, Omit<Partial<User>, 'type'>> = { export const states: Record<string, Omit<Partial<User>, 'type'>> = {
admin: { admin: {
is_admin: true is_admin: true,
}, },
prospect: { 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 => ({ export default (faker: Faker): YouTubeVideo => ({
id: { id: {
videoId: faker.random.alphaNumeric(16) videoId: faker.random.alphaNumeric(16),
}, },
snippet: { snippet: {
title: faker.lorem.sentence(), title: faker.lorem.sentence(),
description: faker.lorem.paragraph(), description: faker.lorem.paragraph(),
thumbnails: { thumbnails: {
default: { default: {
url: faker.image.imageUrl() url: faker.image.imageUrl(),
} },
} },
} },
}) })

View file

@ -19,18 +19,17 @@ declare global {
expect.addSnapshotSerializer(vueSnapshotSerializer) expect.addSnapshotSerializer(vueSnapshotSerializer)
global.ResizeObserver = global.ResizeObserver || globalThis.ResizeObserver = globalThis.ResizeObserver
vi.fn().mockImplementation(() => ({ || vi.fn().mockImplementation(() => ({
disconnect: vi.fn(), disconnect: vi.fn(),
observe: vi.fn(), observe: vi.fn(),
unobserve: vi.fn() unobserve: vi.fn(),
})) }))
globalThis.LemonSqueezy = {
global.LemonSqueezy = {
Url: { Url: {
Open: vi.fn() Open: vi.fn(),
} },
} }
HTMLMediaElement.prototype.load = 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 { noop } from '@/utils'
import MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue' import type MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
import DialogBox from '@/components/ui/DialogBox.vue' import type DialogBox from '@/components/ui/DialogBox.vue'
import Overlay from '@/components/ui/Overlay.vue' import type Overlay from '@/components/ui/Overlay.vue'
export const MessageToasterStub = ref({ export const MessageToasterStub = ref({
info: noop, info: noop,
success: noop, success: noop,
warning: noop, warning: noop,
error: noop error: noop,
}) as unknown as Ref<InstanceType<typeof MessageToaster>> }) as unknown as Ref<InstanceType<typeof MessageToaster>>
export const DialogBoxStub = ref({ export const DialogBoxStub = ref({
@ -17,10 +18,10 @@ export const DialogBoxStub = ref({
success: noop, success: noop,
warning: noop, warning: noop,
error: noop, error: noop,
confirm: noop confirm: noop,
}) as unknown as Ref<InstanceType<typeof DialogBox>> }) as unknown as Ref<InstanceType<typeof DialogBox>>
export const OverlayStub = ref({ export const OverlayStub = ref({
show: noop, show: noop,
hide: noop hide: noop,
}) as unknown as Ref<InstanceType<typeof Overlay>> }) as unknown as Ref<InstanceType<typeof Overlay>>

View file

@ -56,18 +56,18 @@ new class extends UnitTestCase {
id: 42, id: 42,
name: 'IV', name: 'IV',
artist_id: 17, artist_id: 17,
artist_name: 'Led Zeppelin' artist_name: 'Led Zeppelin',
}) })
return this.render(AlbumCard, { return this.render(AlbumCard, {
props: { props: {
album album,
}, },
global: { global: {
stubs: { 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' import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { go } = useRouter() const { go } = useRouter()
const { startDragging } = useDraggable('album') const { startDragging } = useDraggable('album')
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { album, layout } = toRefs(props) 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. // 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) { private async renderComponent (_album?: Album) {
album = _album || factory('album', { album = _album || factory('album', {
name: 'IV' name: 'IV',
}) })
const rendered = this.render(AlbumContextMenu) const rendered = this.render(AlbumContextMenu)

View file

@ -10,7 +10,7 @@ let album: Album
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { 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) await this.renderComponent(mode)
screen.getByTestId('album-info-tracks') screen.getByTestId('album-info-tracks')
@ -38,14 +38,14 @@ new class extends UnitTestCase {
const rendered = this.render(AlbumInfoComponent, { const rendered = this.render(AlbumInfoComponent, {
props: { props: {
album, album,
mode mode,
}, },
global: { global: {
stubs: { stubs: {
TrackList: this.stub(), TrackList: this.stub(),
AlbumThumbnail: this.stub('thumbnail') AlbumThumbnail: this.stub('thumbnail'),
} },
} },
}) })
await this.tick(1) 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 AlbumArtistInfo from '@/components/ui/album-artist/AlbumOrArtistInfo.vue'
import ExpandableContentBlock from '@/components/ui/album-artist/ExpandableContentBlock.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 TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
const { album, mode } = toRefs(props) const { album, mode } = toRefs(props)
const { useLastfm, useSpotify } = useThirdPartyServices() const { useLastfm, useSpotify } = useThirdPartyServices()

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ new class extends UnitTestCase {
super.beforeEach(() => { super.beforeEach(() => {
artist = factory('artist', { artist = factory('artist', {
id: 42, id: 42,
name: 'Led Zeppelin' name: 'Led Zeppelin',
}) })
}) })
} }
@ -63,13 +63,13 @@ new class extends UnitTestCase {
private renderComponent () { private renderComponent () {
return this.render(ArtistCard, { return this.render(ArtistCard, {
props: { props: {
artist artist,
}, },
global: { global: {
stubs: { 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' import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { go } = useRouter() const { go } = useRouter()
const { startDragging } = useDraggable('artist') const { startDragging } = useDraggable('artist')
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { artist, layout } = toRefs(props) 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. // 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) { private async renderComponent (_artist?: Artist) {
artist = _artist || factory('artist', { artist = _artist || factory('artist', {
name: 'Accept' name: 'Accept',
}) })
const rendered = this.render(ArtistContextMenu) const rendered = this.render(ArtistContextMenu)

View file

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

View file

@ -10,7 +10,7 @@ let artist: Artist
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { 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) await this.renderComponent(mode)
if (mode === 'aside') { if (mode === 'aside') {
@ -33,13 +33,13 @@ new class extends UnitTestCase {
const rendered = this.render(ArtistInfoComponent, { const rendered = this.render(ArtistInfoComponent, {
props: { props: {
artist, artist,
mode mode,
}, },
global: { global: {
stubs: { stubs: {
ArtistThumbnail: this.stub('thumbnail') ArtistThumbnail: this.stub('thumbnail'),
} },
} },
}) })
await this.tick(1) 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 TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue' import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'cancel'): void }>()
const { handleHttpError } = useErrorHandler() const { handleHttpError } = useErrorHandler()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()
const emit = defineEmits<{ (e: 'cancel'): void }>()
const email = ref('') const email = ref('')
const loading = ref(false) const loading = ref(false)

View file

@ -1,5 +1,6 @@
import { screen, waitFor } from '@testing-library/vue' 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 UnitTestCase from '@/__tests__/UnitTestCase'
import { authService } from '@/services' import { authService } from '@/services'
import { logger } from '@/utils' import { logger } from '@/utils'
@ -45,9 +46,9 @@ new class extends UnitTestCase {
const { html } = this.render(LoginFrom, { const { html } = this.render(LoginFrom, {
global: { global: {
stubs: { stubs: {
GoogleLoginButton: this.stub('google-login-button') GoogleLoginButton: this.stub('google-login-button'),
} },
} },
}) })
expect(html()).toMatchSnapshot() 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 TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue' import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'loggedin'): void }>()
const DEMO_ACCOUNT = { const DEMO_ACCOUNT = {
email: 'demo@koel.dev', email: 'demo@koel.dev',
password: 'demo' password: 'demo',
} }
const canResetPassword = window.MAILER_CONFIGURED && !window.IS_DEMO const canResetPassword = window.MAILER_CONFIGURED && !window.IS_DEMO
@ -66,8 +68,6 @@ const showingForgotPasswordForm = ref(false)
const showForgotPasswordForm = () => (showingForgotPasswordForm.value = true) const showForgotPasswordForm = () => (showingForgotPasswordForm.value = true)
const emit = defineEmits<{ (e: 'loggedin'): void }>()
const login = async () => { const login = async () => {
try { try {
await authService.login(email.value, password.value) await authService.login(email.value, password.value)
@ -103,10 +103,12 @@ const onSSOSuccess = (token: CompositeToken) => {
* You like to - move it! * You like to - move it!
*/ */
@keyframes shake { @keyframes shake {
8%, 41% { 8%,
41% {
transform: translateX(-10px); transform: translateX(-10px);
} }
25%, 58% { 25%,
58% {
transform: translateX(10px); transform: translateX(10px);
} }
75% { 75% {
@ -115,7 +117,8 @@ const onSSOSuccess = (token: CompositeToken) => {
92% { 92% {
transform: translateX(5px); transform: translateX(5px);
} }
0%, 100% { 0%,
100% {
transform: translateX(0); transform: translateX(0);
} }
} }
@ -123,7 +126,7 @@ const onSSOSuccess = (token: CompositeToken) => {
form { form {
&.error { &.error {
@apply border-red-500; @apply border-red-500;
animation: shake .5s; animation: shake 0.5s;
} }
} }
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

@ -13,8 +13,8 @@ new class extends UnitTestCase {
screen.getByTestId('buttons') screen.getByTestId('buttons')
expect(screen.queryByTestId('activateForm')).toBeNull() expect(screen.queryByTestId('activateForm')).toBeNull()
await this.user.click(screen.getByText('Purchase Koel Plus')) await this.user.click(screen.getByText('Purchase Koel Plus'))
expect(global.LemonSqueezy.Url.Open).toHaveBeenCalledWith( expect(globalThis.LemonSqueezy.Url.Open).toHaveBeenCalledWith(
'https://store.koel.dev/checkout/buy/42?embed=1&media=0&desc=0' '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 Btn from '@/components/ui/form/Btn.vue'
import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue' import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { checkoutUrl } = useKoelPlus() const { checkoutUrl } = useKoelPlus()
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close') const close = () => emit('close')
const showingActivateLicenseForm = ref(false) const showingActivateLicenseForm = ref(false)

View file

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

View file

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

View file

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

View file

@ -34,7 +34,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { faBolt, faCompress, faExpand, faSliders } from '@fortawesome/free-solid-svg-icons' import { faBolt, faCompress, faExpand, faSliders } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, ref } from 'vue' 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 { useRouter } from '@/composables'
import VolumeSlider from '@/components/ui/VolumeSlider.vue' import VolumeSlider from '@/components/ui/VolumeSlider.vue'

View file

@ -40,19 +40,19 @@ new class extends UnitTestCase {
artist_id: 3, artist_id: 3,
album_name: 'Led Zeppelin IV', album_name: 'Led Zeppelin IV',
album_id: 4, album_id: 4,
liked: true liked: true,
}) })
} }
return this.render(Component, { return this.render(Component, {
global: { global: {
stubs: { stubs: {
PlayButton: this.stub('PlayButton') PlayButton: this.stub('PlayButton'),
}, },
provide: { 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 () => { it('goes back if current screen is Queue', async () => {
this.router.$currentRoute.value = { this.router.$currentRoute.value = {
screen: 'Queue', screen: 'Queue',
path: '/queue' path: '/queue',
} }
const goMock = this.mock(Router, 'go') const goMock = this.mock(Router, 'go')

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ new class extends UnitTestCase {
return this.render(MainContent, { return this.render(MainContent, {
global: { global: {
provide: { provide: {
[<symbol>CurrentPlayableKey]: ref(factory('song')) [<symbol>CurrentPlayableKey]: ref(factory('song')),
}, },
stubs: { stubs: {
AlbumArtOverlay, AlbumArtOverlay,
@ -45,9 +45,9 @@ new class extends UnitTestCase {
SearchExcerptsScreen: this.stub('search-excerpts-screen'), SearchExcerptsScreen: this.stub('search-excerpts-screen'),
GenreScreen: this.stub('genre-screen'), GenreScreen: this.stub('genre-screen'),
HomeScreen: this.stub(), // so that home overview requests are not made 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'" /> <ArtistScreen v-if="screen === 'Artist'" />
<SettingsScreen v-if="screen === 'Settings'" /> <SettingsScreen v-if="screen === 'Settings'" />
<ProfileScreen v-if="screen === 'Profile'" /> <ProfileScreen v-if="screen === 'Profile'" />
<PodcastScreen v-if="screen ==='Podcast'" /> <PodcastScreen v-if="screen === 'Podcast'" />
<EpisodeScreen v-if="screen === 'Episode'" /> <EpisodeScreen v-if="screen === 'Episode'" />
<UserListScreen v-if="screen === 'Users'" /> <UserListScreen v-if="screen === 'Users'" />
<YouTubeScreen v-if="useYouTube" v-show="screen === 'YouTube'" /> <YouTubeScreen v-if="useYouTube" v-show="screen === 'YouTube'" />

View file

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

View file

@ -73,7 +73,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import isMobile from 'ismobilejs' import isMobile from 'ismobilejs'
import { faBars } from '@fortawesome/free-solid-svg-icons' 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 { albumStore, artistStore, preferenceStore } from '@/stores'
import { useErrorHandler, useThirdPartyServices } from '@/composables' import { useErrorHandler, useThirdPartyServices } from '@/composables'
import { eventBus, isSong, requireInjection } from '@/utils' import { eventBus, isSong, requireInjection } from '@/utils'
@ -115,7 +116,9 @@ const fetchSongInfo = async (song: Song) => {
} }
watch(playable, song => { watch(playable, song => {
if (!song || !isSong(song)) return if (!song || !isSong(song)) {
return
}
fetchSongInfo(song) fetchSongInfo(song)
}, { immediate: true }) }, { immediate: true })
@ -134,7 +137,7 @@ onMounted(() => isMobile.any || (activeTab.value = preferenceStore.active_extra_
@layer utilities { @layer utilities {
.btn-group { .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' import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), { const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), {
modelValue: null modelValue: null,
}) })
const emit = defineEmits<{ (e: 'update:modelValue', value: ExtraPanelTab | null): void }>() const emit = defineEmits<{ (e: 'update:modelValue', value: ExtraPanelTab | null): void }>()
@ -55,7 +55,7 @@ const { useYouTube } = useThirdPartyServices()
const value = computed({ const value = computed({
get: () => props.modelValue, 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) 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 onDragStart = (event: DragEvent) => startDragging(event, folder.value)
const onDragOver = (event: DragEvent) => { const onDragOver = (event: DragEvent) => {
if (!acceptsDrop(event)) return false if (!acceptsDrop(event)) {
return false
}
event.preventDefault() event.preventDefault()
droppable.value = true droppable.value = true
@ -79,12 +81,16 @@ const onDragLeave = () => (droppable.value = false)
const onDrop = async (event: DragEvent) => { const onDrop = async (event: DragEvent) => {
droppable.value = false droppable.value = false
if (!acceptsDrop(event)) return false if (!acceptsDrop(event)) {
return false
}
event.preventDefault() event.preventDefault()
const playlist = await resolveDroppedValue<Playlist>(event) 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) await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
} }
@ -92,7 +98,9 @@ const onDrop = async (event: DragEvent) => {
const onDragLeaveHatch = () => (droppableOnHatch.value = false) const onDragLeaveHatch = () => (droppableOnHatch.value = false)
const onDragOverHatch = (event: DragEvent) => { const onDragOverHatch = (event: DragEvent) => {
if (!acceptsDrop(event)) return false if (!acceptsDrop(event)) {
return false
}
event.preventDefault() event.preventDefault()
droppableOnHatch.value = true droppableOnHatch.value = true
@ -105,7 +113,9 @@ const onDropOnHatch = async (event: DragEvent) => {
const playlist = (await resolveDroppedValue<Playlist>(event))! 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 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. // otherwise, the user is trying to remove the playlist from the folder.
event.stopPropagation() event.stopPropagation()
@ -115,7 +125,7 @@ const onDropOnHatch = async (event: DragEvent) => {
const onContextMenu = (event: MouseEvent) => eventBus.emit( const onContextMenu = (event: MouseEvent) => eventBus.emit(
'PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', 'PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED',
event, event,
folder.value folder.value,
) )
</script> </script>

View file

@ -9,8 +9,8 @@ new class extends UnitTestCase {
renderComponent (list: PlaylistLike) { renderComponent (list: PlaylistLike) {
this.render(PlaylistSidebarItem, { this.render(PlaylistSidebarItem, {
props: { props: {
list list,
} },
}) })
} }
@ -26,10 +26,10 @@ new class extends UnitTestCase {
}) })
it.each<FavoriteList['name'] | RecentlyPlayedList['name']>(['Favorites', 'Recently Played']) 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 = { const list: FavoriteList | RecentlyPlayedList = {
name, name,
songs: [] songs: [],
} }
const emitMock = this.mock(eventBus, 'emit') 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' import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
const props = defineProps<{ list: PlaylistLike }>()
const { onRouteChanged } = useRouter() const { onRouteChanged } = useRouter()
const { startDragging } = useDraggable('playlist') const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist']) const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist'])
@ -39,7 +40,6 @@ const droppable = ref(false)
const { addToPlaylist } = usePlaylistManagement() const { addToPlaylist } = usePlaylistManagement()
const props = defineProps<{ list: PlaylistLike }>()
const { list } = toRefs(props) const { list } = toRefs(props)
const isPlaylist = (list: PlaylistLike): list is Playlist => 'id' in list 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 current = ref(false)
const url = computed(() => { const url = computed(() => {
if (isPlaylist(list.value)) return `#/playlist/${list.value.id}` if (isPlaylist(list.value)) {
if (isFavoriteList(list.value)) return '#/favorites' return `#/playlist/${list.value.id}`
if (isRecentlyPlayedList(list.value)) return '#/recently-played' }
if (isFavoriteList(list.value)) {
return '#/favorites'
}
if (isRecentlyPlayedList(list.value)) {
return '#/recently-played'
}
throw new Error('Invalid playlist-like type.') throw new Error('Invalid playlist-like type.')
}) })
const contentEditable = computed(() => { const contentEditable = computed(() => {
if (isRecentlyPlayedList(list.value)) return false if (isRecentlyPlayedList(list.value)) {
if (isFavoriteList(list.value)) return true return false
}
if (isFavoriteList(list.value)) {
return true
}
return !list.value.is_smart 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 onDragStart = (event: DragEvent) => isPlaylist(list.value) && startDragging(event, list.value)
const onDragOver = (event: DragEvent) => { const onDragOver = (event: DragEvent) => {
if (!contentEditable.value) return false if (!contentEditable.value) {
if (!acceptsDrop(event)) return false return false
}
if (!acceptsDrop(event)) {
return false
}
event.preventDefault() event.preventDefault()
droppable.value = true droppable.value = true
@ -87,12 +101,18 @@ const onDragLeave = () => (droppable.value = false)
const onDrop = async (event: DragEvent) => { const onDrop = async (event: DragEvent) => {
droppable.value = false droppable.value = false
if (!contentEditable.value) return false if (!contentEditable.value) {
if (!acceptsDrop(event)) return false return false
}
if (!acceptsDrop(event)) {
return false
}
const playables = await resolveDroppedItems(event) const playables = await resolveDroppedItems(event)
if (!playables?.length) return false if (!playables?.length) {
return false
}
if (isFavoriteList(list.value)) { if (isFavoriteList(list.value)) {
await favoriteStore.like(playables) await favoriteStore.like(playables)

View file

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

View file

@ -1,6 +1,6 @@
<template> <template>
<nav <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" 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" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@ -29,8 +29,8 @@
</section> </section>
<SidebarToggleButton <SidebarToggleButton
class="opacity-0 no-hover:hidden group-hover:opacity-100 transition"
v-model="expanded" v-model="expanded"
class="opacity-0 no-hover:hidden group-hover:opacity-100 transition"
:class="expanded || 'opacity-100'" :class="expanded || 'opacity-100'"
/> />
</nav> </nav>
@ -68,10 +68,14 @@ let tmpShowingHandler: number | undefined
const tmpShowing = ref(false) const tmpShowing = ref(false)
const onMouseEnter = () => { const onMouseEnter = () => {
if (expanded.value) return; if (expanded.value) {
return
}
tmpShowingHandler = window.setTimeout(() => { tmpShowingHandler = window.setTimeout(() => {
if (expanded.value) return if (expanded.value) {
return
}
tmpShowing.value = true tmpShowing.value = true
}, 500) }, 500)
} }
@ -132,7 +136,7 @@ nav {
@mixin themed-background; @mixin themed-background;
transform: translateX(-100vw); transform: translateX(-100vw);
transition: transform .2s ease-in-out; transition: transform 0.2s ease-in-out;
&.showing { &.showing {
transform: translateX(0); transform: translateX(0);

View file

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

View file

@ -27,9 +27,9 @@ import { ref } from 'vue'
import { useRouter } from '@/composables' import { useRouter } from '@/composables'
import { eventBus } from '@/utils' 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, href: undefined,
screen: undefined screen: undefined,
}) })
const current = ref(false) const current = ref(false)

View file

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

View file

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

View file

@ -29,7 +29,9 @@ const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'playables') const favorites = toRef(favoriteStore.state, 'playables')
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => { 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 // if the playlist's folder is not found, it's an orphan
// this can happen if the playlist belongs to another user (collaborative playlist) // 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({ const value = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: value => emit('update:modelValue', value) set: value => emit('update:modelValue', value),
}) })
</script> </script>

View file

@ -39,9 +39,9 @@ new class extends UnitTestCase {
return this.render(AboutKoelModel, { return this.render(AboutKoelModel, {
global: { global: {
stubs: { 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> <a href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a>
and quite a few and quite a few
<a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a>&nbsp;<a <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" href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
>contributors</a>. >contributors</a>.
</p> </p>
<CreditsBlock v-if="isDemo" /> <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 BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
import CreditsBlock from '@/components/meta/CreditsBlock.vue' import CreditsBlock from '@/components/meta/CreditsBlock.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { const {
shouldNotifyNewVersion, shouldNotifyNewVersion,
currentVersion, currentVersion,
latestVersion, latestVersion,
latestVersionReleaseUrl latestVersionReleaseUrl,
} = useNewVersionNotification() } = useNewVersionNotification()
const { isPlus, license } = useKoelPlus() const { isPlus, license } = useKoelPlus()
const { isAdmin } = useAuthorization() const { isAdmin } = useAuthorization()
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close') const close = () => emit('close')
const showPlusModal = () => { const showPlusModal = () => {
@ -89,7 +90,7 @@ const showPlusModal = () => {
eventBus.emit('MODAL_SHOW_KOEL_PLUS') eventBus.emit('MODAL_SHOW_KOEL_PLUS')
} }
const isDemo = window.IS_DEMO; const isDemo = window.IS_DEMO
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View file

@ -11,7 +11,7 @@ new class extends UnitTestCase {
const getMock = this.mock(http, 'get').mockResolvedValue([ const getMock = this.mock(http, 'get').mockResolvedValue([
{ name: 'Foo', url: 'https://foo.com' }, { name: 'Foo', url: 'https://foo.com' },
{ name: 'Bar', url: 'https://bar.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) const { html } = this.render(CreditsBlock)

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useContextMenu } from '@/composables' import { useContextMenu } from '@/composables'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { Events } from '@/config' import type { Events } from '@/config'
const { base, ContextMenu, open, trigger } = useContextMenu() 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> = { const actionToEventMap: Record<Action, keyof Events> = {
'new-playlist': 'MODAL_SHOW_CREATE_PLAYLIST_FORM', 'new-playlist': 'MODAL_SHOW_CREATE_PLAYLIST_FORM',
'new-smart-playlist': 'MODAL_SHOW_CREATE_SMART_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])) 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', { eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', {
top: bottom, top: bottom,
left: right left: right,
}) })
} }
</script> </script>

View file

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

View file

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

View file

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

View file

@ -15,9 +15,9 @@ new class extends UnitTestCase {
this.render(EditPlaylistFolderForm, { this.render(EditPlaylistFolderForm, {
global: { global: {
provide: { provide: {
[<symbol>ModalContextKey]: [ref({ folder })] [<symbol>ModalContextKey]: [ref({ folder })],
} },
} },
}) })
await this.type(screen.getByPlaceholderText('Folder name'), 'Your 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 TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue' import FormRow from '@/components/ui/form/FormRow.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox() const { showConfirmDialog } = useDialogBox()
@ -33,6 +34,8 @@ const folder = useModal().getFromContext<PlaylistFolder>('folder')
const name = ref(folder.name) const name = ref(folder.name)
const close = () => emit('close')
const submit = async () => { const submit = async () => {
showOverlay() showOverlay()
@ -47,9 +50,6 @@ const submit = async () => {
} }
} }
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => { const maybeClose = async () => {
if (name.value.trim() === folder.name) { if (name.value.trim() === folder.name) {
close() close()

View file

@ -14,7 +14,7 @@ new class extends UnitTestCase {
const playlist = factory('playlist', { const playlist = factory('playlist', {
name: 'My playlist', name: 'My playlist',
folder_id: playlistFolderStore.state.folders[0].id folder_id: playlistFolderStore.state.folders[0].id,
}) })
playlistStore.state.playlists = [playlist] playlistStore.state.playlists = [playlist]
@ -24,9 +24,9 @@ new class extends UnitTestCase {
this.render(EditPlaylistForm, { this.render(EditPlaylistForm, {
global: { global: {
provide: { provide: {
[<symbol>ModalContextKey]: [ref({ playlist })] [<symbol>ModalContextKey]: [ref({ playlist })],
} },
} },
}) })
await this.type(screen.getByPlaceholderText('Playlist name'), 'Your playlist') await this.type(screen.getByPlaceholderText('Playlist name'), 'Your playlist')
@ -35,7 +35,7 @@ new class extends UnitTestCase {
await waitFor(() => { await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(playlist, { expect(updateMock).toHaveBeenCalledWith(playlist, {
name: 'Your 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 FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue' import SelectBox from '@/components/ui/form/SelectBox.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox() const { showConfirmDialog } = useDialogBox()
@ -53,7 +54,6 @@ const name = ref(playlist.name)
const folderId = ref(playlist.folder_id) const folderId = ref(playlist.folder_id)
const folders = toRef(playlistFolderStore.state, 'folders') const folders = toRef(playlistFolderStore.state, 'folders')
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close') const close = () => emit('close')
const submit = async () => { const submit = async () => {
@ -62,7 +62,7 @@ const submit = async () => {
try { try {
await playlistStore.update(playlist, { await playlistStore.update(playlist, {
name: name.value, name: name.value,
folder_id: folderId.value folder_id: folderId.value,
}) })
toastSuccess('Playlist updated.') toastSuccess('Playlist updated.')
@ -92,6 +92,6 @@ form {
} }
label.folder { label.folder {
flex: .6; flex: 0.6;
} }
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { sortBy } from 'lodash' 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 { useAuthorization, useDialogBox, useErrorHandler } from '@/composables'
import { playlistCollaborationService } from '@/services' import { playlistCollaborationService } from '@/services'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
@ -29,7 +30,7 @@ const { playlist } = toRefs(props)
const { currentUser } = useAuthorization() const { currentUser } = useAuthorization()
const { showConfirmDialog } = useDialogBox() const { showConfirmDialog } = useDialogBox()
let collaborators: Ref<PlaylistCollaborator[]> = ref([]) const collaborators: Ref<PlaylistCollaborator[]> = ref([])
const loading = ref(false) const loading = ref(false)
const currentUserIsOwner = computed(() => currentUser.value?.id === playlist.value.user_id) const currentUserIsOwner = computed(() => currentUser.value?.id === playlist.value.user_id)
@ -41,10 +42,14 @@ const fetchCollaborators = async () => {
collaborators.value = sortBy( collaborators.value = sortBy(
await playlistCollaborationService.fetchCollaborators(playlist.value), await playlistCollaborationService.fetchCollaborators(playlist.value),
({ id }) => { ({ id }) => {
if (id === currentUser.value.id) return 0 if (id === currentUser.value.id) {
if (id === playlist.value.user_id) return 1 return 0
}
if (id === playlist.value.user_id) {
return 1
}
return 2 return 2
} },
) )
} finally { } finally {
loading.value = false loading.value = false
@ -53,10 +58,12 @@ const fetchCollaborators = async () => {
const removeCollaborator = async (collaborator: PlaylistCollaborator) => { const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
const deadSure = await showConfirmDialog( 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 { try {
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id) 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 }), collaborator: factory('playlist-collaborator', { id: currentUser.id + 1 }),
removable: true, removable: true,
manageable: true, manageable: true,
role: 'owner' role: 'owner',
}) })
expect(screen.queryByTitle('This is you!')).toBeNull() 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 () => { it('shows a badge when current user is the collaborator', async () => {
const currentUser = factory('user') const currentUser = factory('user')
this.be(currentUser).renderComponent({ this.be(currentUser).renderComponent({
collaborator: factory('playlist-collaborator', collaborator: factory('playlist-collaborator', {
{ id: currentUser.id,
id: currentUser.id, name: currentUser.name,
name: currentUser.name, avatar: currentUser.avatar,
avatar: currentUser.avatar }),
}
),
removable: true, removable: true,
manageable: true, manageable: true,
role: 'owner' role: 'owner',
}) })
screen.getByTitle('This is you!') screen.getByTitle('This is you!')
@ -43,7 +41,7 @@ new class extends UnitTestCase {
collaborator, collaborator,
removable: true, removable: true,
manageable: true, manageable: true,
role: 'owner' role: 'owner',
}) })
screen.getByText('Owner') screen.getByText('Owner')
@ -52,7 +50,7 @@ new class extends UnitTestCase {
collaborator, collaborator,
removable: true, removable: true,
manageable: true, manageable: true,
role: 'contributor' role: 'contributor',
}) })
screen.getByText('Contributor') screen.getByText('Contributor')
@ -64,7 +62,7 @@ new class extends UnitTestCase {
collaborator, collaborator,
removable: true, removable: true,
manageable: true, manageable: true,
role: 'owner' role: 'owner',
}) })
await this.user.click(screen.getByRole('button', { name: 'Remove' })) await this.user.click(screen.getByRole('button', { name: 'Remove' }))
@ -74,18 +72,18 @@ new class extends UnitTestCase {
} }
private renderComponent (props: { private renderComponent (props: {
collaborator: PlaylistCollaborator, collaborator: PlaylistCollaborator
removable: boolean, removable: boolean
manageable: boolean, manageable: boolean
role: 'owner' | 'contributor' role: 'owner' | 'contributor'
}) { }) {
return this.render(Component, { return this.render(Component, {
props, props,
global: { global: {
stubs: { 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' import { useAuthorization } from '@/composables'
const props = defineProps<{ const props = defineProps<{
collaborator: PlaylistCollaborator, collaborator: PlaylistCollaborator
removable: boolean, removable: boolean
manageable: boolean, manageable: boolean
role: 'owner' | 'contributor' role: 'owner' | 'contributor'
}>() }>()
const emit = defineEmits<{ (e: 'remove'): void }>()
const { collaborator, removable, role } = toRefs(props) const { collaborator, removable, role } = toRefs(props)
const { currentUser } = useAuthorization() const { currentUser } = useAuthorization()
const emit = defineEmits<{ (e: 'remove'): void }>()
</script> </script>
<style lang="postcss" scoped> <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 () => { 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 user = factory('user')
const playlist = factory('playlist', { const playlist = factory('playlist', {
user_id: user.id + 1 user_id: user.id + 1,
}) })
await this.renderComponent(playlist, user) await this.renderComponent(playlist, user)
@ -156,7 +156,7 @@ new class extends UnitTestCase {
private async renderComponent (playlist: Playlist, user: User | null = null) { private async renderComponent (playlist: Playlist, user: User | null = null) {
userStore.state.current = user || factory('user', { userStore.state.current = user || factory('user', {
id: playlist.user_id id: playlist.user_id,
}) })
this.render(PlaylistContextMenu) this.render(PlaylistContextMenu)

View file

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

View file

@ -68,13 +68,14 @@ import {
useMessageToaster, useMessageToaster,
useModal, useModal,
useOverlay, useOverlay,
useSmartPlaylistForm useSmartPlaylistForm,
} from '@/composables' } from '@/composables'
import CheckBox from '@/components/ui/form/CheckBox.vue' import CheckBox from '@/components/ui/form/CheckBox.vue'
import TextInput from '@/components/ui/form/TextInput.vue' import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue' import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue' import SelectBox from '@/components/ui/form/SelectBox.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox() const { showConfirmDialog } = useDialogBox()
@ -94,10 +95,9 @@ const {
RuleGroup, RuleGroup,
collectedRuleGroups, collectedRuleGroups,
addGroup, addGroup,
onGroupChanged onGroupChanged,
} = useSmartPlaylistForm(mutablePlaylist.rules) } = useSmartPlaylistForm(mutablePlaylist.rules)
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close') const close = () => emit('close')
const maybeClose = async () => { 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 SelectBox from '@/components/ui/form/SelectBox.vue'
import Btn from '@/components/ui/form/Btn.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 RuleInput = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/SmartPlaylistRuleInput.vue'))
const props = defineProps<{ rule: SmartPlaylistRule }>()
const { rule } = toRefs(props) const { rule } = toRefs(props)
const mutatedRule = Object.assign({}, rule.value) as SmartPlaylistRule const mutatedRule = Object.assign({}, rule.value) as SmartPlaylistRule
@ -78,8 +84,8 @@ if (!operator) {
selectedOperator.value = operator selectedOperator.value = operator
const isOriginalOperatorSelected = computed(() => { const isOriginalOperatorSelected = computed(() => {
return selectedModel.value?.name === mutatedRule.model.name && return selectedModel.value?.name === mutatedRule.model.name
selectedOperator.value?.operator === mutatedRule.operator && selectedOperator.value?.operator === mutatedRule.operator
}) })
const availableInputs = computed<{ id: string, value: any }[]>(() => { 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) { for (let i = 0, inputCount = selectedOperator.value.inputs || 1; i < inputCount; ++i) {
inputs.push({ inputs.push({
id: `${mutatedRule.model.name}_${selectedOperator.value.operator}_${i}`, 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 valueSuffix = computed(() => selectedOperator.value?.unit || selectedModel.value?.unit)
const emit = defineEmits<{
(e: 'input', rule: SmartPlaylistRule): void,
(e: 'remove'): void
}>()
const onInput = () => { const onInput = () => {
emit('input', { emit('input', {
id: mutatedRule.id, id: mutatedRule.id,
model: selectedModel.value!, model: selectedModel.value!,
operator: selectedOperator.value?.operator!, operator: selectedOperator.value!.operator,
value: availableInputs.value.map(input => input.value) value: availableInputs.value.map(input => input.value),
}) })
} }

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