mirror of
https://github.com/koel/koel
synced 2024-11-12 23:47:09 +00:00
feat: use a better ESLint setup (#1850)
This commit is contained in:
parent
9cb99af0e4
commit
5f37982641
384 changed files with 4844 additions and 3455 deletions
52
.eslintrc
52
.eslintrc
|
@ -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
49
.vscode/settings.json
vendored
Normal 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
31
eslint.config.js
Normal 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/**',
|
||||
]
|
||||
})
|
19
package.json
19
package.json
|
@ -39,6 +39,7 @@
|
|||
"youtube-player": "^3.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.7.3",
|
||||
"@commitlint/cli": "^19.4.0",
|
||||
"@commitlint/config-conventional": "^19.2.2",
|
||||
"@faker-js/faker": "^6.2.0",
|
||||
|
@ -53,8 +54,6 @@
|
|||
"@types/pusher-js": "^4.2.2",
|
||||
"@types/three": "^0.144.0",
|
||||
"@types/youtube-player": "^5.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
||||
"@typescript-eslint/parser": "^4.11.1",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vueuse/components": "^10.9.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
|
@ -63,12 +62,8 @@
|
|||
"autoprefixer": "^10.4.19",
|
||||
"cross-env": "^7.0.3",
|
||||
"cypress": "^9.5.4",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.1",
|
||||
"eslint-plugin-vue": "^8.7.1",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-format": "^0.1.2",
|
||||
"events": "^3.3.0",
|
||||
"factoria": "^4.0.0",
|
||||
"husky": "^9.1.5",
|
||||
|
@ -92,7 +87,7 @@
|
|||
"yarn": "^1.22.22"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint ./resources/assets/js/**/*.ts --no-error-on-unmatched-pattern && eslint ./cypress/**/*.ts --no-error-on-unmatched-pattern",
|
||||
"lint": "eslint ./resources/assets/js --fix",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'",
|
||||
|
@ -109,11 +104,11 @@
|
|||
"**/*.php": [
|
||||
"composer cs"
|
||||
],
|
||||
"resources/assets/**/*.ts": [
|
||||
"eslint"
|
||||
"resources/assets/js/**/*.{ts,vue}": [
|
||||
"eslint --fix"
|
||||
],
|
||||
"cypress/**/*.ts": [
|
||||
"eslint"
|
||||
"eslint --fix"
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import isMobile from 'ismobilejs'
|
||||
import { isObject, mergeWith } from 'lodash'
|
||||
import { cleanup, createEvent, fireEvent, render, RenderOptions } from '@testing-library/vue'
|
||||
import type { RenderOptions } from '@testing-library/vue'
|
||||
import { cleanup, createEvent, fireEvent, render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { commonStore, userStore } from '@/stores'
|
||||
|
@ -11,8 +12,8 @@ import { DialogBoxStub, MessageToasterStub, OverlayStub } from '@/__tests__/stub
|
|||
import { routes } from '@/config'
|
||||
import Router from '@/router'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'
|
||||
import { EventType } from '@testing-library/dom/types/events'
|
||||
import type { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'
|
||||
import type { EventType } from '@testing-library/dom/types/events'
|
||||
|
||||
// A deep-merge function that
|
||||
// - supports symbols as keys (_.merge doesn't)
|
||||
|
@ -20,7 +21,9 @@ import { EventType } from '@testing-library/dom/types/events'
|
|||
// Credit: https://stackoverflow.com/a/60598589/794641
|
||||
const deepMerge = (first: object, second: object) => {
|
||||
return mergeWith(first, second, (a, b) => {
|
||||
if (!isObject(b)) return b
|
||||
if (!isObject(b)) {
|
||||
return b
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return Array.isArray(a) ? [...a, ...b] : { ...a, ...b }
|
||||
|
@ -28,7 +31,9 @@ const deepMerge = (first: object, second: object) => {
|
|||
}
|
||||
|
||||
const setPropIfNotExists = (obj: object | null, prop: any, value: any) => {
|
||||
if (!obj) return
|
||||
if (!obj) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, prop)) {
|
||||
obj[prop] = value
|
||||
|
@ -46,7 +51,7 @@ export default abstract class UnitTestCase {
|
|||
this.user = userEvent.setup({ delay: null }) // @see https://github.com/testing-library/user-event/issues/833
|
||||
|
||||
this.setReadOnlyProperty(navigator, 'clipboard', {
|
||||
writeText: vi.fn()
|
||||
writeText: vi.fn(),
|
||||
})
|
||||
|
||||
this.beforeEach()
|
||||
|
@ -113,12 +118,12 @@ export default abstract class UnitTestCase {
|
|||
'koel-focus': {},
|
||||
'koel-tooltip': {},
|
||||
'koel-hide-broken-icon': {},
|
||||
'koel-overflow-fade': {}
|
||||
'koel-overflow-fade': {},
|
||||
},
|
||||
components: {
|
||||
Icon: this.stub('Icon')
|
||||
}
|
||||
}
|
||||
Icon: this.stub('Icon'),
|
||||
},
|
||||
},
|
||||
}, this.supplyRequiredProvides(options)))
|
||||
}
|
||||
|
||||
|
@ -148,7 +153,7 @@ export default abstract class UnitTestCase {
|
|||
|
||||
protected stub (testId = 'stub') {
|
||||
return defineComponent({
|
||||
template: `<br data-testid="${testId}"/>`
|
||||
template: `<br data-testid="${testId}"/>`,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -162,8 +167,8 @@ export default abstract class UnitTestCase {
|
|||
return Object.defineProperties(obj, {
|
||||
[prop]: {
|
||||
value,
|
||||
configurable: true
|
||||
}
|
||||
configurable: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -172,7 +177,7 @@ export default abstract class UnitTestCase {
|
|||
await this.user.type(element, value)
|
||||
}
|
||||
|
||||
protected async trigger (element: HTMLElement, key: EventType | string, options?: {}) {
|
||||
protected async trigger (element: HTMLElement, key: EventType | string, options?: object = {}) {
|
||||
await fireEvent(element, createEvent[key](element, options))
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Album => {
|
||||
return {
|
||||
|
@ -8,7 +8,7 @@ export default (faker: Faker): Album => {
|
|||
id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default
|
||||
name: faker.lorem.sentence(),
|
||||
cover: faker.image.imageUrl(),
|
||||
created_at: faker.date.past().toISOString()
|
||||
created_at: faker.date.past().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,6 @@ export const states: Record<string, Omit<Partial<Album>, 'type'>> = {
|
|||
id: 1,
|
||||
name: 'Unknown Album',
|
||||
artist_id: 1,
|
||||
artist_name: 'Unknown Artist'
|
||||
}
|
||||
artist_name: 'Unknown Artist',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
import factory from 'factoria'
|
||||
|
||||
export default (faker: Faker): AlbumInfo => ({
|
||||
cover: faker.image.imageUrl(),
|
||||
wiki: {
|
||||
summary: faker.lorem.sentence(),
|
||||
full: faker.lorem.sentences(4)
|
||||
full: faker.lorem.sentences(4),
|
||||
},
|
||||
tracks: factory('album-track', 8),
|
||||
url: faker.internet.url()
|
||||
url: faker.internet.url(),
|
||||
})
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): AlbumTrack => ({
|
||||
title: faker.lorem.sentence(),
|
||||
length: faker.datatype.number({ min: 180, max: 1_800 })
|
||||
length: faker.datatype.number({ min: 180, max: 1_800 }),
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Artist => {
|
||||
return {
|
||||
|
@ -6,17 +6,17 @@ export default (faker: Faker): Artist => {
|
|||
id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default
|
||||
name: faker.name.findName(),
|
||||
image: 'foo.jpg',
|
||||
created_at: faker.date.past().toISOString()
|
||||
created_at: faker.date.past().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const states: Record<string, Omit<Partial<Artist>, 'type'>> = {
|
||||
unknown: {
|
||||
id: 1,
|
||||
name: 'Unknown Artist'
|
||||
name: 'Unknown Artist',
|
||||
},
|
||||
various: {
|
||||
id: 2,
|
||||
name: 'Various Artists'
|
||||
}
|
||||
name: 'Various Artists',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): ArtistInfo => ({
|
||||
image: faker.image.imageUrl(),
|
||||
bio: {
|
||||
summary: faker.lorem.sentence(),
|
||||
full: faker.lorem.sentences(4)
|
||||
full: faker.lorem.sentences(4),
|
||||
},
|
||||
url: faker.internet.url()
|
||||
url: faker.internet.url(),
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Episode => {
|
||||
return {
|
||||
|
@ -15,6 +15,6 @@ export default (faker: Faker): Episode => {
|
|||
episode_image: faker.image.imageUrl(),
|
||||
podcast_id: faker.datatype.uuid(),
|
||||
podcast_title: faker.lorem.sentence(),
|
||||
podcast_author: faker.name.findName()
|
||||
podcast_author: faker.name.findName(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
import { genres } from '@/config'
|
||||
|
||||
export default (faker: Faker): Genre => {
|
||||
|
@ -6,6 +6,6 @@ export default (faker: Faker): Genre => {
|
|||
type: 'genres',
|
||||
name: faker.helpers.arrayElement(genres),
|
||||
song_count: faker.datatype.number({ min: 1, max: 1_000 }),
|
||||
length: faker.datatype.number({ min: 300, max: 300_000 })
|
||||
length: faker.datatype.number({ min: 300, max: 300_000 }),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import factoria, { Factoria } from 'factoria'
|
||||
import type { Factoria } from 'factoria'
|
||||
import factoria from 'factoria'
|
||||
import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory'
|
||||
import songFactory, { states as songStates } from '@/__tests__/factory/songFactory'
|
||||
import albumFactory, { states as albumStates } from '@/__tests__/factory/albumFactory'
|
||||
|
@ -16,26 +17,26 @@ import genreFactory from '@/__tests__/factory/genreFactory'
|
|||
import playlistCollaboratorFactory from '@/__tests__/factory/playlistCollaboratorFactory'
|
||||
import episodeFactory from '@/__tests__/factory/episodeFactory'
|
||||
import podcastFactory from '@/__tests__/factory/podcastFactory'
|
||||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
type ModelToTypeMap = {
|
||||
artist: Artist
|
||||
interface ModelToTypeMap {
|
||||
'artist': Artist
|
||||
'artist-info': ArtistInfo
|
||||
album: Album
|
||||
'album': Album
|
||||
'album-track': AlbumTrack
|
||||
'album-info': AlbumInfo
|
||||
song: Song
|
||||
interaction: Interaction
|
||||
genre: Genre
|
||||
video: YouTubeVideo
|
||||
'song': Song
|
||||
'interaction': Interaction
|
||||
'genre': Genre
|
||||
'video': YouTubeVideo
|
||||
'smart-playlist-rule': SmartPlaylistRule
|
||||
'smart-playlist-rule-group': SmartPlaylistRuleGroup
|
||||
playlist: Playlist
|
||||
'playlist': Playlist
|
||||
'playlist-folder': PlaylistFolder
|
||||
user: User
|
||||
'user': User
|
||||
'playlist-collaborator': PlaylistCollaborator
|
||||
episode: Episode
|
||||
podcast: Podcast
|
||||
'episode': Episode
|
||||
'podcast': Podcast
|
||||
}
|
||||
|
||||
type Model = keyof ModelToTypeMap
|
||||
|
@ -44,30 +45,30 @@ type Overrides<M extends Model> = Factoria.Overrides<ModelToTypeMap[M]>
|
|||
const define = <M extends Model>(
|
||||
model: M,
|
||||
handle: (faker: Faker) => Overrides<M>,
|
||||
states?: Record<string, Factoria.StateDefinition>
|
||||
states?: Record<string, Factoria.StateDefinition>,
|
||||
) => factoria.define(model, handle, states)
|
||||
|
||||
function factory <M extends Model>(
|
||||
function factory<M extends Model> (
|
||||
model: M,
|
||||
overrides?: Overrides<M>
|
||||
): ModelToTypeMap[M]
|
||||
|
||||
function factory <M extends Model>(
|
||||
function factory<M extends Model> (
|
||||
model: M,
|
||||
count: 1,
|
||||
overrides?: Overrides<M>
|
||||
): ModelToTypeMap[M]
|
||||
|
||||
function factory <M extends Model>(
|
||||
function factory<M extends Model> (
|
||||
model: M,
|
||||
count: number,
|
||||
overrides?: Overrides<M>
|
||||
): ModelToTypeMap[M][]
|
||||
|
||||
function factory <M extends Model>(
|
||||
function factory<M extends Model> (
|
||||
model: M,
|
||||
count: number|Overrides<M> = 1,
|
||||
overrides?: Overrides<M>
|
||||
count: number | Overrides<M> = 1,
|
||||
overrides?: Overrides<M>,
|
||||
) {
|
||||
return typeof count === 'number'
|
||||
? count === 1 ? factoria(model, overrides) : factoria(model, count, overrides)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Interaction => ({
|
||||
type: 'interactions',
|
||||
id: faker.datatype.number({ min: 1 }),
|
||||
song_id: faker.datatype.uuid(),
|
||||
liked: faker.datatype.boolean(),
|
||||
play_count: faker.datatype.number({ min: 1 })
|
||||
play_count: faker.datatype.number({ min: 1 }),
|
||||
})
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): PlaylistCollaborator => ({
|
||||
type: 'playlist-collaborators',
|
||||
id: faker.datatype.number(),
|
||||
name: faker.name.findName(),
|
||||
avatar: 'https://gravatar.com/foo'
|
||||
avatar: 'https://gravatar.com/foo',
|
||||
})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import factory from 'factoria'
|
||||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Playlist => ({
|
||||
type: 'playlists',
|
||||
|
@ -18,10 +18,10 @@ export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 't
|
|||
smart: _ => ({
|
||||
is_smart: true,
|
||||
rules: [
|
||||
factory('smart-playlist-rule-group')
|
||||
]
|
||||
factory('smart-playlist-rule-group'),
|
||||
],
|
||||
}),
|
||||
orphan: _ => ({
|
||||
folder_id: null
|
||||
})
|
||||
folder_id: null,
|
||||
}),
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): PlaylistFolder => ({
|
||||
type: 'playlist-folders',
|
||||
id: faker.datatype.uuid(),
|
||||
name: faker.random.word()
|
||||
name: faker.random.word(),
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Podcast => {
|
||||
return {
|
||||
|
@ -14,7 +14,7 @@ export default (faker: Faker): Podcast => {
|
|||
created_at: faker.date.past().toISOString(),
|
||||
state: {
|
||||
current_episode: null,
|
||||
progresses: {}
|
||||
}
|
||||
progresses: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
import models from '@/config/smart-playlist/models'
|
||||
|
||||
export default (faker: Faker): SmartPlaylistRule => ({
|
||||
id: faker.datatype.uuid(),
|
||||
model: faker.helpers.arrayElement(models),
|
||||
operator: faker.helpers.arrayElement<SmartPlaylistOperator['operator']>(['is', 'contains', 'isNot']),
|
||||
value: [faker.random.word()]
|
||||
value: [faker.random.word()],
|
||||
})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
import factory from 'factoria'
|
||||
|
||||
export default (faker: Faker): SmartPlaylistRuleGroup => ({
|
||||
id: faker.datatype.uuid(),
|
||||
rules: factory('smart-playlist-rule', 3)
|
||||
rules: factory('smart-playlist-rule', 3),
|
||||
})
|
||||
|
|
|
@ -27,12 +27,12 @@ const generate = (partOfCompilation = false): Song => {
|
|||
liked: faker.datatype.boolean(),
|
||||
is_public: faker.datatype.boolean(),
|
||||
created_at: faker.date.past().toISOString(),
|
||||
playback_state: 'Stopped'
|
||||
playback_state: 'Stopped',
|
||||
}
|
||||
}
|
||||
|
||||
export default (): Song => generate()
|
||||
|
||||
export const states: Record<string, Partial<Song>> = {
|
||||
partOfCompilation: generate(true)
|
||||
partOfCompilation: generate(true),
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): User => ({
|
||||
type: 'users',
|
||||
|
@ -11,14 +11,14 @@ export default (faker: Faker): User => ({
|
|||
avatar: 'https://gravatar.com/foo',
|
||||
preferences: undefined,
|
||||
sso_provider: null,
|
||||
sso_id: null
|
||||
sso_id: null,
|
||||
})
|
||||
|
||||
export const states: Record<string, Omit<Partial<User>, 'type'>> = {
|
||||
admin: {
|
||||
is_admin: true
|
||||
is_admin: true,
|
||||
},
|
||||
prospect: {
|
||||
is_prospect: true
|
||||
}
|
||||
is_prospect: true,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
import type { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): YouTubeVideo => ({
|
||||
id: {
|
||||
videoId: faker.random.alphaNumeric(16)
|
||||
videoId: faker.random.alphaNumeric(16),
|
||||
},
|
||||
snippet: {
|
||||
title: faker.lorem.sentence(),
|
||||
description: faker.lorem.paragraph(),
|
||||
thumbnails: {
|
||||
default: {
|
||||
url: faker.image.imageUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
url: faker.image.imageUrl(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -19,18 +19,17 @@ declare global {
|
|||
|
||||
expect.addSnapshotSerializer(vueSnapshotSerializer)
|
||||
|
||||
global.ResizeObserver = global.ResizeObserver ||
|
||||
vi.fn().mockImplementation(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn()
|
||||
}))
|
||||
globalThis.ResizeObserver = globalThis.ResizeObserver
|
||||
|| vi.fn().mockImplementation(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
global.LemonSqueezy = {
|
||||
globalThis.LemonSqueezy = {
|
||||
Url: {
|
||||
Open: vi.fn()
|
||||
}
|
||||
Open: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
HTMLMediaElement.prototype.load = vi.fn()
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import { Ref, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { noop } from '@/utils'
|
||||
|
||||
import MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
|
||||
import DialogBox from '@/components/ui/DialogBox.vue'
|
||||
import Overlay from '@/components/ui/Overlay.vue'
|
||||
import type MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
|
||||
import type DialogBox from '@/components/ui/DialogBox.vue'
|
||||
import type Overlay from '@/components/ui/Overlay.vue'
|
||||
|
||||
export const MessageToasterStub = ref({
|
||||
info: noop,
|
||||
success: noop,
|
||||
warning: noop,
|
||||
error: noop
|
||||
error: noop,
|
||||
}) as unknown as Ref<InstanceType<typeof MessageToaster>>
|
||||
|
||||
export const DialogBoxStub = ref({
|
||||
|
@ -17,10 +18,10 @@ export const DialogBoxStub = ref({
|
|||
success: noop,
|
||||
warning: noop,
|
||||
error: noop,
|
||||
confirm: noop
|
||||
confirm: noop,
|
||||
}) as unknown as Ref<InstanceType<typeof DialogBox>>
|
||||
|
||||
export const OverlayStub = ref({
|
||||
show: noop,
|
||||
hide: noop
|
||||
hide: noop,
|
||||
}) as unknown as Ref<InstanceType<typeof Overlay>>
|
||||
|
|
|
@ -56,18 +56,18 @@ new class extends UnitTestCase {
|
|||
id: 42,
|
||||
name: 'IV',
|
||||
artist_id: 17,
|
||||
artist_name: 'Led Zeppelin'
|
||||
artist_name: 'Led Zeppelin',
|
||||
})
|
||||
|
||||
return this.render(AlbumCard, {
|
||||
props: {
|
||||
album
|
||||
album,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
AlbumArtistThumbnail: this.stub('thumbnail')
|
||||
}
|
||||
}
|
||||
AlbumArtistThumbnail: this.stub('thumbnail'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,10 +39,10 @@ import { useDraggable, useRouter } from '@/composables'
|
|||
|
||||
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { go } = useRouter()
|
||||
const { startDragging } = useDraggable('album')
|
||||
|
||||
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { album, layout } = toRefs(props)
|
||||
|
||||
// We're not checking for supports_batch_downloading here, as the number of songs on the album is not yet known.
|
||||
|
|
|
@ -85,7 +85,7 @@ new class extends UnitTestCase {
|
|||
|
||||
private async renderComponent (_album?: Album) {
|
||||
album = _album || factory('album', {
|
||||
name: 'IV'
|
||||
name: 'IV',
|
||||
})
|
||||
|
||||
const rendered = this.render(AlbumContextMenu)
|
||||
|
|
|
@ -10,7 +10,7 @@ let album: Album
|
|||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
|
||||
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async mode => {
|
||||
await this.renderComponent(mode)
|
||||
|
||||
screen.getByTestId('album-info-tracks')
|
||||
|
@ -38,14 +38,14 @@ new class extends UnitTestCase {
|
|||
const rendered = this.render(AlbumInfoComponent, {
|
||||
props: {
|
||||
album,
|
||||
mode
|
||||
mode,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
TrackList: this.stub(),
|
||||
AlbumThumbnail: this.stub('thumbnail')
|
||||
}
|
||||
}
|
||||
AlbumThumbnail: this.stub('thumbnail'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await this.tick(1)
|
||||
|
|
|
@ -40,9 +40,10 @@ import AlbumThumbnail from '@/components/ui/album-artist/AlbumOrArtistThumbnail.
|
|||
import AlbumArtistInfo from '@/components/ui/album-artist/AlbumOrArtistInfo.vue'
|
||||
import ExpandableContentBlock from '@/components/ui/album-artist/ExpandableContentBlock.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
|
||||
|
||||
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
||||
|
||||
const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
|
||||
const { album, mode } = toRefs(props)
|
||||
|
||||
const { useLastfm, useSpotify } = useThirdPartyServices()
|
||||
|
|
|
@ -14,8 +14,8 @@ new class extends UnitTestCase {
|
|||
this.render(AlbumTrackList, {
|
||||
props: {
|
||||
album,
|
||||
tracks: factory('album-track', 3)
|
||||
}
|
||||
tracks: factory('album-track', 3),
|
||||
},
|
||||
})
|
||||
|
||||
await this.tick()
|
||||
|
|
|
@ -30,7 +30,7 @@ new class extends UnitTestCase {
|
|||
|
||||
const track = factory('album-track', {
|
||||
title: 'Fahrstuhl to Heaven',
|
||||
length: 280
|
||||
length: 280,
|
||||
})
|
||||
|
||||
const matchMock = this.mock(songStore, 'match', matchedSong)
|
||||
|
@ -38,13 +38,13 @@ new class extends UnitTestCase {
|
|||
const rendered = this.render(AlbumTrackListItem, {
|
||||
props: {
|
||||
album,
|
||||
track
|
||||
track,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>PlayablesKey]: ref(songsToMatchAgainst)
|
||||
}
|
||||
}
|
||||
[<symbol>PlayablesKey]: ref(songsToMatchAgainst),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(matchMock).toHaveBeenCalledWith('Fahrstuhl to Heaven', songsToMatchAgainst)
|
||||
|
|
|
@ -13,16 +13,18 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, Ref, toRefs } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, toRefs } from 'vue'
|
||||
import { songStore } from '@/stores'
|
||||
import { authService, playbackService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { requireInjection, secondsToHis } from '@/utils'
|
||||
import { PlayablesKey } from '@/symbols'
|
||||
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack }>()
|
||||
|
||||
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
|
||||
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack }>()
|
||||
const { album, track } = toRefs(props)
|
||||
|
||||
const { useAppleMusic } = useThirdPartyServices()
|
||||
|
@ -44,7 +46,8 @@ const play = () => matchedSong.value && playbackService.play(matchedSong.value)
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
.track-list-item {
|
||||
&:focus, &.active {
|
||||
&:focus,
|
||||
&.active {
|
||||
span.title {
|
||||
@apply text-k-highlight;
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ new class extends UnitTestCase {
|
|||
super.beforeEach(() => {
|
||||
artist = factory('artist', {
|
||||
id: 42,
|
||||
name: 'Led Zeppelin'
|
||||
name: 'Led Zeppelin',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -63,13 +63,13 @@ new class extends UnitTestCase {
|
|||
private renderComponent () {
|
||||
return this.render(ArtistCard, {
|
||||
props: {
|
||||
artist
|
||||
artist,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
AlbumArtistThumbnail: this.stub('thumbnail')
|
||||
}
|
||||
}
|
||||
AlbumArtistThumbnail: this.stub('thumbnail'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,10 +31,10 @@ import { useDraggable, useRouter } from '@/composables'
|
|||
|
||||
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { go } = useRouter()
|
||||
const { startDragging } = useDraggable('artist')
|
||||
|
||||
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { artist, layout } = toRefs(props)
|
||||
|
||||
// We're not checking for supports_batch_downloading here, as the number of songs by the artist is not yet known.
|
||||
|
|
|
@ -82,7 +82,7 @@ new class extends UnitTestCase {
|
|||
|
||||
private async renderComponent (_artist?: Artist) {
|
||||
artist = _artist || factory('artist', {
|
||||
name: 'Accept'
|
||||
name: 'Accept',
|
||||
})
|
||||
|
||||
const rendered = this.render(ArtistContextMenu)
|
||||
|
|
|
@ -30,7 +30,7 @@ const allowDownload = toRef(commonStore.state, 'allows_download')
|
|||
|
||||
const isStandardArtist = computed(() =>
|
||||
!artistStore.isUnknown(artist.value!)
|
||||
&& !artistStore.isVarious(artist.value!)
|
||||
&& !artistStore.isVarious(artist.value!),
|
||||
)
|
||||
|
||||
const play = () => trigger(async () => {
|
||||
|
|
|
@ -10,7 +10,7 @@ let artist: Artist
|
|||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
|
||||
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async mode => {
|
||||
await this.renderComponent(mode)
|
||||
|
||||
if (mode === 'aside') {
|
||||
|
@ -33,13 +33,13 @@ new class extends UnitTestCase {
|
|||
const rendered = this.render(ArtistInfoComponent, {
|
||||
props: {
|
||||
artist,
|
||||
mode
|
||||
mode,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ArtistThumbnail: this.stub('thumbnail')
|
||||
}
|
||||
}
|
||||
ArtistThumbnail: this.stub('thumbnail'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await this.tick(1)
|
||||
|
|
|
@ -30,10 +30,10 @@ import Btn from '@/components/ui/form/Btn.vue'
|
|||
import TextInput from '@/components/ui/form/TextInput.vue'
|
||||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void }>()
|
||||
const { handleHttpError } = useErrorHandler()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void }>()
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { expect, it, Mock } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { authService } from '@/services'
|
||||
import { logger } from '@/utils'
|
||||
|
@ -45,9 +46,9 @@ new class extends UnitTestCase {
|
|||
const { html } = this.render(LoginFrom, {
|
||||
global: {
|
||||
stubs: {
|
||||
GoogleLoginButton: this.stub('google-login-button')
|
||||
}
|
||||
}
|
||||
GoogleLoginButton: this.stub('google-login-button'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(html()).toMatchSnapshot()
|
||||
|
|
|
@ -51,9 +51,11 @@ import GoogleLoginButton from '@/components/auth/sso/GoogleLoginButton.vue'
|
|||
import TextInput from '@/components/ui/form/TextInput.vue'
|
||||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
||||
|
||||
const DEMO_ACCOUNT = {
|
||||
email: 'demo@koel.dev',
|
||||
password: 'demo'
|
||||
password: 'demo',
|
||||
}
|
||||
|
||||
const canResetPassword = window.MAILER_CONFIGURED && !window.IS_DEMO
|
||||
|
@ -66,8 +68,6 @@ const showingForgotPasswordForm = ref(false)
|
|||
|
||||
const showForgotPasswordForm = () => (showingForgotPasswordForm.value = true)
|
||||
|
||||
const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
await authService.login(email.value, password.value)
|
||||
|
@ -103,10 +103,12 @@ const onSSOSuccess = (token: CompositeToken) => {
|
|||
* You like to - move it!
|
||||
*/
|
||||
@keyframes shake {
|
||||
8%, 41% {
|
||||
8%,
|
||||
41% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
25%, 58% {
|
||||
25%,
|
||||
58% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
75% {
|
||||
|
@ -115,7 +117,8 @@ const onSSOSuccess = (token: CompositeToken) => {
|
|||
92% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
@ -123,7 +126,7 @@ const onSSOSuccess = (token: CompositeToken) => {
|
|||
form {
|
||||
&.error {
|
||||
@apply border-red-500;
|
||||
animation: shake .5s;
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -12,7 +12,7 @@ new class extends UnitTestCase {
|
|||
|
||||
await this.router.activateRoute({
|
||||
path: '_',
|
||||
screen: 'Password.Reset'
|
||||
screen: 'Password.Reset',
|
||||
}, { payload: 'Zm9vQGJhci5jb218bXktdG9rZW4=' })
|
||||
|
||||
this.render(ResetPasswordForm)
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { authService } from '@/services'
|
||||
import { base64Decode } from '@/utils'
|
||||
import { base64Decode, logger } from '@/utils'
|
||||
import { useErrorHandler, useMessageToaster, useRouter } from '@/composables'
|
||||
|
||||
import PasswordField from '@/components/ui/form/PasswordField.vue'
|
||||
|
@ -39,6 +39,7 @@ const validPayload = computed(() => email.value && token.value)
|
|||
try {
|
||||
[email.value, token.value] = base64Decode(decodeURIComponent(getRouteParam('payload')!)).split('|')
|
||||
} catch (error: unknown) {
|
||||
logger.error(error)
|
||||
toastError('Invalid reset password link.')
|
||||
}
|
||||
|
||||
|
|
|
@ -12,15 +12,15 @@ new class extends UnitTestCase {
|
|||
.mockResolvedValue(factory.states('prospect')('user'))
|
||||
|
||||
const acceptMock = this.mock(invitationService, 'accept').mockResolvedValue({
|
||||
token: 'my-api-token',
|
||||
'audio-token': 'my-audio-token'
|
||||
'token': 'my-api-token',
|
||||
'audio-token': 'my-audio-token',
|
||||
})
|
||||
|
||||
await this.router.activateRoute({
|
||||
path: '_',
|
||||
screen: 'Invitation.Accept'
|
||||
screen: 'Invitation.Accept',
|
||||
}, {
|
||||
token: 'my-token'
|
||||
token: 'my-token',
|
||||
})
|
||||
|
||||
this.render(AcceptInvitation)
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
<Btn :disabled="loading" class="!rounded-l-none" type="submit">Activate</Btn>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { plusService } from '@/services'
|
||||
|
|
|
@ -13,8 +13,8 @@ new class extends UnitTestCase {
|
|||
screen.getByTestId('buttons')
|
||||
expect(screen.queryByTestId('activateForm')).toBeNull()
|
||||
await this.user.click(screen.getByText('Purchase Koel Plus'))
|
||||
expect(global.LemonSqueezy.Url.Open).toHaveBeenCalledWith(
|
||||
'https://store.koel.dev/checkout/buy/42?embed=1&media=0&desc=0'
|
||||
expect(globalThis.LemonSqueezy.Url.Open).toHaveBeenCalledWith(
|
||||
'https://store.koel.dev/checkout/buy/42?embed=1&media=0&desc=0',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -42,9 +42,10 @@ import { useKoelPlus } from '@/composables'
|
|||
import Btn from '@/components/ui/form/Btn.vue'
|
||||
import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const { checkoutUrl } = useKoelPlus()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const showingActivateLicenseForm = ref(false)
|
||||
|
|
|
@ -2,7 +2,7 @@ import { it } from 'vitest'
|
|||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { eventBus } from '@/utils'
|
||||
import { Events } from '@/config'
|
||||
import type { Events } from '@/config'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import ModalWrapper from './ModalWrapper.vue'
|
||||
|
||||
|
@ -23,7 +23,7 @@ new class extends UnitTestCase {
|
|||
['about-koel', 'MODAL_SHOW_ABOUT_KOEL', undefined],
|
||||
['koel-plus', 'MODAL_SHOW_KOEL_PLUS', undefined],
|
||||
['equalizer', 'MODAL_SHOW_EQUALIZER', undefined],
|
||||
['add-podcast-form', 'MODAL_SHOW_ADD_PODCAST_FORM', undefined]
|
||||
['add-podcast-form', 'MODAL_SHOW_ADD_PODCAST_FORM', undefined],
|
||||
])('shows %s modal', async (modalName, eventName, eventParams?: any) => {
|
||||
this.render(ModalWrapper, {
|
||||
global: {
|
||||
|
@ -42,9 +42,9 @@ new class extends UnitTestCase {
|
|||
Equalizer: this.stub('equalizer'),
|
||||
InviteUserForm: this.stub('invite-user-form'),
|
||||
KoelPlus: this.stub('koel-plus'),
|
||||
PlaylistCollaborationModal: this.stub('playlist-collaboration')
|
||||
}
|
||||
}
|
||||
PlaylistCollaborationModal: this.stub('playlist-collaboration'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
eventBus.emit(eventName, eventParams)
|
||||
|
|
|
@ -28,7 +28,7 @@ const modalNameToComponentMap = {
|
|||
'equalizer': defineAsyncComponent(() => import('@/components/ui/equalizer/Equalizer.vue')),
|
||||
'invite-user-form': defineAsyncComponent(() => import('@/components/user/InviteUserForm.vue')),
|
||||
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
|
||||
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue'))
|
||||
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
|
||||
}
|
||||
|
||||
type ModalName = keyof typeof modalNameToComponentMap
|
||||
|
@ -53,7 +53,7 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
|
|||
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, playables?) => {
|
||||
context.value = {
|
||||
folder,
|
||||
playables: playables ? arrayify(playables) : []
|
||||
playables: playables ? arrayify(playables) : [],
|
||||
}
|
||||
|
||||
activeModalName.value = 'create-playlist-form'
|
||||
|
@ -74,7 +74,7 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
|
|||
.on('MODAL_SHOW_EDIT_SONG_FORM', (songs, initialTab: EditSongFormTabName = 'details') => {
|
||||
context.value = {
|
||||
initialTab,
|
||||
songs: arrayify(songs)
|
||||
songs: arrayify(songs),
|
||||
}
|
||||
|
||||
activeModalName.value = 'edit-song-form'
|
||||
|
@ -95,10 +95,13 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
dialog {
|
||||
:deep(form), :deep(>div) {
|
||||
:deep(form),
|
||||
:deep(> div) {
|
||||
@apply relative;
|
||||
|
||||
> header, > main, > footer {
|
||||
> header,
|
||||
> main,
|
||||
> footer {
|
||||
@apply px-6 py-5;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,9 +27,9 @@ new class extends UnitTestCase {
|
|||
global: {
|
||||
stubs: {
|
||||
Equalizer: this.stub('Equalizer'),
|
||||
Volume: this.stub('Volume')
|
||||
}
|
||||
}
|
||||
Volume: this.stub('Volume'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { faBolt, faCompress, faExpand, faSliders } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { eventBus, isAudioContextSupported as useEqualizer, isFullscreenSupported } from '@/utils'
|
||||
import { eventBus, isFullscreenSupported, isAudioContextSupported as useEqualizer } from '@/utils'
|
||||
import { useRouter } from '@/composables'
|
||||
|
||||
import VolumeSlider from '@/components/ui/VolumeSlider.vue'
|
||||
|
|
|
@ -40,19 +40,19 @@ new class extends UnitTestCase {
|
|||
artist_id: 3,
|
||||
album_name: 'Led Zeppelin IV',
|
||||
album_id: 4,
|
||||
liked: true
|
||||
liked: true,
|
||||
})
|
||||
}
|
||||
|
||||
return this.render(Component, {
|
||||
global: {
|
||||
stubs: {
|
||||
PlayButton: this.stub('PlayButton')
|
||||
PlayButton: this.stub('PlayButton'),
|
||||
},
|
||||
provide: {
|
||||
[<symbol>CurrentPlayableKey]: ref(playable)
|
||||
}
|
||||
}
|
||||
[<symbol>CurrentPlayableKey]: ref(playable),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ new class extends UnitTestCase {
|
|||
it('goes back if current screen is Queue', async () => {
|
||||
this.router.$currentRoute.value = {
|
||||
screen: 'Queue',
|
||||
path: '/queue'
|
||||
path: '/queue',
|
||||
}
|
||||
|
||||
const goMock = this.mock(Router, 'go')
|
||||
|
|
|
@ -26,7 +26,7 @@ const { go, isCurrentScreen } = useRouter()
|
|||
const { toastWarning, toastSuccess } = useMessageToaster()
|
||||
|
||||
const { acceptsDrop, resolveDroppedItems } = useDroppable(
|
||||
['playables', 'album', 'artist', 'playlist', 'playlist-folder']
|
||||
['playables', 'album', 'artist', 'playlist', 'playlist-folder'],
|
||||
)
|
||||
|
||||
const droppable = ref(false)
|
||||
|
|
|
@ -15,15 +15,15 @@ new class extends UnitTestCase {
|
|||
album_cover: 'https://via.placeholder.com/150',
|
||||
playback_state: 'Playing',
|
||||
artist_id: 10,
|
||||
artist_name: 'Led Zeppelin'
|
||||
artist_name: 'Led Zeppelin',
|
||||
})
|
||||
|
||||
expect(this.render(FooterSongInfo, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>CurrentPlayableKey]: ref(song)
|
||||
}
|
||||
}
|
||||
[<symbol>CurrentPlayableKey]: ref(song),
|
||||
},
|
||||
},
|
||||
}).html()).toMatchSnapshot()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -29,17 +29,23 @@ const { startDragging } = useDraggable('playables')
|
|||
const song = requireInjection(CurrentPlayableKey, ref())
|
||||
|
||||
const cover = computed(() => {
|
||||
if (!song.value) return defaultCover
|
||||
if (!song.value) {
|
||||
return defaultCover
|
||||
}
|
||||
return getPlayableProp(song.value, 'album_cover', 'episode_image')
|
||||
})
|
||||
|
||||
const artistOrPodcastUri = computed(() => {
|
||||
if (!song.value) return ''
|
||||
if (!song.value) {
|
||||
return ''
|
||||
}
|
||||
return isSong(song.value) ? `#/artist/${song.value?.artist_id}` : `#/podcasts/${song.value.podcast_id}`
|
||||
})
|
||||
|
||||
const artistOrPodcastName = computed(() => {
|
||||
if (!song.value) return ''
|
||||
if (!song.value) {
|
||||
return ''
|
||||
}
|
||||
return getPlayableProp(song.value, 'artist_name', 'podcast_title')
|
||||
})
|
||||
|
||||
|
|
|
@ -38,12 +38,16 @@ const root = ref<HTMLElement>()
|
|||
const artist = ref<Artist>()
|
||||
|
||||
const requestContextMenu = (event: MouseEvent) => {
|
||||
if (document.fullscreenElement) return
|
||||
if (document.fullscreenElement) {
|
||||
return
|
||||
}
|
||||
playable.value && eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', event, playable.value)
|
||||
}
|
||||
|
||||
watch(playable, async () => {
|
||||
if (!playable.value) return
|
||||
if (!playable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isSong(playable.value)) {
|
||||
artist.value = await artistStore.resolve(playable.value.artist_id)
|
||||
|
@ -51,7 +55,9 @@ watch(playable, async () => {
|
|||
})
|
||||
|
||||
const appBackgroundImage = computed(() => {
|
||||
if (!playable.value || !isSong(playable.value)) return 'none'
|
||||
if (!playable.value || !isSong(playable.value)) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const src = artist.value?.image ?? playable.value.album_cover
|
||||
return src ? `url(${src})` : 'none'
|
||||
|
@ -71,7 +77,9 @@ const initPlaybackRelatedServices = async () => {
|
|||
}
|
||||
|
||||
watch(preferenceStore.initialized, async initialized => {
|
||||
if (!initialized) return
|
||||
if (!initialized) {
|
||||
return
|
||||
}
|
||||
await initPlaybackRelatedServices()
|
||||
}, { immediate: true })
|
||||
|
||||
|
@ -80,7 +88,9 @@ const setupControlHidingTimer = () => {
|
|||
}
|
||||
|
||||
const showControls = throttle(() => {
|
||||
if (!document.fullscreenElement) return
|
||||
if (!document.fullscreenElement) {
|
||||
return
|
||||
}
|
||||
|
||||
root.value?.classList.remove('hide-controls')
|
||||
window.clearTimeout(hideControlsTimeout)
|
||||
|
@ -103,7 +113,7 @@ eventBus.on('FULLSCREEN_TOGGLE', () => toggleFullscreen())
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
footer {
|
||||
box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2);
|
||||
box-shadow: 0 0 30px 20px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.fullscreen-backdrop {
|
||||
background-image: v-bind(appBackgroundImage);
|
||||
|
@ -119,17 +129,20 @@ footer {
|
|||
}
|
||||
|
||||
.wrapper {
|
||||
@apply z-[3]
|
||||
@apply z-[3];
|
||||
}
|
||||
|
||||
&::before {
|
||||
@apply bg-black bg-repeat absolute top-0 left-0 opacity-50 z-[1] pointer-events-none -m-[20rem];
|
||||
content: '';
|
||||
background-image: linear-gradient(135deg, #111 25%, transparent 25%),
|
||||
linear-gradient(225deg, #111 25%, transparent 25%),
|
||||
linear-gradient(45deg, #111 25%, transparent 25%),
|
||||
linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%);
|
||||
background-position: 6px 0, 6px 0, 0 0, 0 0;
|
||||
linear-gradient(225deg, #111 25%, transparent 25%), linear-gradient(45deg, #111 25%, transparent 25%),
|
||||
linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%);
|
||||
background-position:
|
||||
6px 0,
|
||||
6px 0,
|
||||
0 0,
|
||||
0 0;
|
||||
background-size: 6px 6px;
|
||||
width: calc(100% + 40rem);
|
||||
height: calc(100% + 40rem);
|
||||
|
|
|
@ -31,7 +31,7 @@ new class extends UnitTestCase {
|
|||
return this.render(MainContent, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>CurrentPlayableKey]: ref(factory('song'))
|
||||
[<symbol>CurrentPlayableKey]: ref(factory('song')),
|
||||
},
|
||||
stubs: {
|
||||
AlbumArtOverlay,
|
||||
|
@ -45,9 +45,9 @@ new class extends UnitTestCase {
|
|||
SearchExcerptsScreen: this.stub('search-excerpts-screen'),
|
||||
GenreScreen: this.stub('genre-screen'),
|
||||
HomeScreen: this.stub(), // so that home overview requests are not made
|
||||
Visualizer: this.stub('visualizer')
|
||||
}
|
||||
}
|
||||
Visualizer: this.stub('visualizer'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<ArtistScreen v-if="screen === 'Artist'" />
|
||||
<SettingsScreen v-if="screen === 'Settings'" />
|
||||
<ProfileScreen v-if="screen === 'Profile'" />
|
||||
<PodcastScreen v-if="screen ==='Podcast'" />
|
||||
<PodcastScreen v-if="screen === 'Podcast'" />
|
||||
<EpisodeScreen v-if="screen === 'Episode'" />
|
||||
<UserListScreen v-if="screen === 'Users'" />
|
||||
<YouTubeScreen v-if="useYouTube" v-show="screen === 'YouTube'" />
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { ref, Ref } from 'vue'
|
||||
import { expect, it, Mock } from 'vitest'
|
||||
import { RenderResult, screen, waitFor } from '@testing-library/vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { Mock } from 'vitest'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { RenderResult } from '@testing-library/vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { albumStore, artistStore, commonStore, preferenceStore } from '@/stores'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
|
@ -80,12 +83,12 @@ new class extends UnitTestCase {
|
|||
AlbumInfo: this.stub('album-info'),
|
||||
ArtistInfo: this.stub('artist-info'),
|
||||
YouTubeVideoList: this.stub('youtube-video-list'),
|
||||
ExtraPanelTabHeader: this.stub()
|
||||
ExtraPanelTabHeader: this.stub(),
|
||||
},
|
||||
provide: {
|
||||
[<symbol>CurrentPlayableKey]: songRef
|
||||
}
|
||||
}
|
||||
[<symbol>CurrentPlayableKey]: songRef,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return [rendered, resolveArtistMock, resolveAlbumMock]
|
||||
|
|
|
@ -73,7 +73,8 @@
|
|||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, Ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||
import { albumStore, artistStore, preferenceStore } from '@/stores'
|
||||
import { useErrorHandler, useThirdPartyServices } from '@/composables'
|
||||
import { eventBus, isSong, requireInjection } from '@/utils'
|
||||
|
@ -115,7 +116,9 @@ const fetchSongInfo = async (song: Song) => {
|
|||
}
|
||||
|
||||
watch(playable, song => {
|
||||
if (!song || !isSong(song)) return
|
||||
if (!song || !isSong(song)) {
|
||||
return
|
||||
}
|
||||
fetchSongInfo(song)
|
||||
}, { immediate: true })
|
||||
|
||||
|
@ -134,7 +137,7 @@ onMounted(() => isMobile.any || (activeTab.value = preferenceStore.active_extra_
|
|||
|
||||
@layer utilities {
|
||||
.btn-group {
|
||||
@apply flex md:flex-col justify-between items-center gap-1 md:gap-3
|
||||
@apply flex md:flex-col justify-between items-center gap-1 md:gap-3;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ import { useThirdPartyServices } from '@/composables'
|
|||
import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), {
|
||||
modelValue: null
|
||||
modelValue: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: ExtraPanelTab | null): void }>()
|
||||
|
@ -55,7 +55,7 @@ const { useYouTube } = useThirdPartyServices()
|
|||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const toggleTab = (tab: ExtraPanelTab) => (value.value = value.value === tab ? null : tab)
|
||||
|
|
|
@ -67,7 +67,9 @@ const toggle = () => (opened.value = !opened.value)
|
|||
const onDragStart = (event: DragEvent) => startDragging(event, folder.value)
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
if (!acceptsDrop(event)) return false
|
||||
if (!acceptsDrop(event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
droppable.value = true
|
||||
|
@ -79,12 +81,16 @@ const onDragLeave = () => (droppable.value = false)
|
|||
const onDrop = async (event: DragEvent) => {
|
||||
droppable.value = false
|
||||
|
||||
if (!acceptsDrop(event)) return false
|
||||
if (!acceptsDrop(event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const playlist = await resolveDroppedValue<Playlist>(event)
|
||||
if (!playlist || playlist.folder_id === folder.value.id) return
|
||||
if (!playlist || playlist.folder_id === folder.value.id) {
|
||||
return
|
||||
}
|
||||
|
||||
await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
|
||||
}
|
||||
|
@ -92,7 +98,9 @@ const onDrop = async (event: DragEvent) => {
|
|||
const onDragLeaveHatch = () => (droppableOnHatch.value = false)
|
||||
|
||||
const onDragOverHatch = (event: DragEvent) => {
|
||||
if (!acceptsDrop(event)) return false
|
||||
if (!acceptsDrop(event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
droppableOnHatch.value = true
|
||||
|
@ -105,7 +113,9 @@ const onDropOnHatch = async (event: DragEvent) => {
|
|||
const playlist = (await resolveDroppedValue<Playlist>(event))!
|
||||
|
||||
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.
|
||||
if (playlist.folder_id !== folder.value.id) return
|
||||
if (playlist.folder_id !== folder.value.id) {
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise, the user is trying to remove the playlist from the folder.
|
||||
event.stopPropagation()
|
||||
|
@ -115,7 +125,7 @@ const onDropOnHatch = async (event: DragEvent) => {
|
|||
const onContextMenu = (event: MouseEvent) => eventBus.emit(
|
||||
'PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED',
|
||||
event,
|
||||
folder.value
|
||||
folder.value,
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
|
@ -9,8 +9,8 @@ new class extends UnitTestCase {
|
|||
renderComponent (list: PlaylistLike) {
|
||||
this.render(PlaylistSidebarItem, {
|
||||
props: {
|
||||
list
|
||||
}
|
||||
list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -26,10 +26,10 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it.each<FavoriteList['name'] | RecentlyPlayedList['name']>(['Favorites', 'Recently Played'])
|
||||
('does not request context menu if not playlist', async (name) => {
|
||||
('does not request context menu if not playlist', async name => { // eslint-disable-line no-unexpected-multiline
|
||||
const list: FavoriteList | RecentlyPlayedList = {
|
||||
name,
|
||||
songs: []
|
||||
songs: [],
|
||||
}
|
||||
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
|
|
@ -31,6 +31,7 @@ import { useDraggable, useDroppable, usePlaylistManagement, useRouter } from '@/
|
|||
|
||||
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
|
||||
|
||||
const props = defineProps<{ list: PlaylistLike }>()
|
||||
const { onRouteChanged } = useRouter()
|
||||
const { startDragging } = useDraggable('playlist')
|
||||
const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist'])
|
||||
|
@ -39,7 +40,6 @@ const droppable = ref(false)
|
|||
|
||||
const { addToPlaylist } = usePlaylistManagement()
|
||||
|
||||
const props = defineProps<{ list: PlaylistLike }>()
|
||||
const { list } = toRefs(props)
|
||||
|
||||
const isPlaylist = (list: PlaylistLike): list is Playlist => 'id' in list
|
||||
|
@ -49,16 +49,26 @@ const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList =>
|
|||
const current = ref(false)
|
||||
|
||||
const url = computed(() => {
|
||||
if (isPlaylist(list.value)) return `#/playlist/${list.value.id}`
|
||||
if (isFavoriteList(list.value)) return '#/favorites'
|
||||
if (isRecentlyPlayedList(list.value)) return '#/recently-played'
|
||||
if (isPlaylist(list.value)) {
|
||||
return `#/playlist/${list.value.id}`
|
||||
}
|
||||
if (isFavoriteList(list.value)) {
|
||||
return '#/favorites'
|
||||
}
|
||||
if (isRecentlyPlayedList(list.value)) {
|
||||
return '#/recently-played'
|
||||
}
|
||||
|
||||
throw new Error('Invalid playlist-like type.')
|
||||
})
|
||||
|
||||
const contentEditable = computed(() => {
|
||||
if (isRecentlyPlayedList(list.value)) return false
|
||||
if (isFavoriteList(list.value)) return true
|
||||
if (isRecentlyPlayedList(list.value)) {
|
||||
return false
|
||||
}
|
||||
if (isFavoriteList(list.value)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !list.value.is_smart
|
||||
})
|
||||
|
@ -73,8 +83,12 @@ const onContextMenu = (event: MouseEvent) => {
|
|||
const onDragStart = (event: DragEvent) => isPlaylist(list.value) && startDragging(event, list.value)
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
if (!contentEditable.value) return false
|
||||
if (!acceptsDrop(event)) return false
|
||||
if (!contentEditable.value) {
|
||||
return false
|
||||
}
|
||||
if (!acceptsDrop(event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
droppable.value = true
|
||||
|
@ -87,12 +101,18 @@ const onDragLeave = () => (droppable.value = false)
|
|||
const onDrop = async (event: DragEvent) => {
|
||||
droppable.value = false
|
||||
|
||||
if (!contentEditable.value) return false
|
||||
if (!acceptsDrop(event)) return false
|
||||
if (!contentEditable.value) {
|
||||
return false
|
||||
}
|
||||
if (!acceptsDrop(event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const playables = await resolveDroppedItems(event)
|
||||
|
||||
if (!playables?.length) return false
|
||||
if (!playables?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isFavoriteList(list.value)) {
|
||||
await favoriteStore.like(playables)
|
||||
|
|
|
@ -11,7 +11,7 @@ const standardItems = [
|
|||
'Artists',
|
||||
'Genres',
|
||||
'Favorites',
|
||||
'Recently Played'
|
||||
'Recently Played',
|
||||
]
|
||||
|
||||
const adminItems = [...standardItems, 'Users', 'Upload', 'Settings']
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<nav
|
||||
:class="{ collapsed: !expanded, 'tmp-showing': tmpShowing, showing: mobileShowing }"
|
||||
:class="{ 'collapsed': !expanded, 'tmp-showing': tmpShowing, 'showing': mobileShowing }"
|
||||
class="group left-0 top-0 flex flex-col fixed h-full w-full md:relative md:w-k-sidebar-width z-[999] md:z-10"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
|
@ -29,8 +29,8 @@
|
|||
</section>
|
||||
|
||||
<SidebarToggleButton
|
||||
class="opacity-0 no-hover:hidden group-hover:opacity-100 transition"
|
||||
v-model="expanded"
|
||||
class="opacity-0 no-hover:hidden group-hover:opacity-100 transition"
|
||||
:class="expanded || 'opacity-100'"
|
||||
/>
|
||||
</nav>
|
||||
|
@ -68,10 +68,14 @@ let tmpShowingHandler: number | undefined
|
|||
const tmpShowing = ref(false)
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (expanded.value) return;
|
||||
if (expanded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
tmpShowingHandler = window.setTimeout(() => {
|
||||
if (expanded.value) return
|
||||
if (expanded.value) {
|
||||
return
|
||||
}
|
||||
tmpShowing.value = true
|
||||
}, 500)
|
||||
}
|
||||
|
@ -132,7 +136,7 @@ nav {
|
|||
@mixin themed-background;
|
||||
|
||||
transform: translateX(-100vw);
|
||||
transition: transform .2s ease-in-out;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
|
||||
&.showing {
|
||||
transform: translateX(0);
|
||||
|
|
|
@ -14,7 +14,7 @@ new class extends UnitTestCase {
|
|||
|
||||
await this.router.activateRoute({
|
||||
screen: 'Home',
|
||||
path: '_'
|
||||
path: '_',
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('sidebar-item').classList.contains('current')).toBe(true)
|
||||
|
@ -34,11 +34,11 @@ new class extends UnitTestCase {
|
|||
props: {
|
||||
icon: faHome,
|
||||
href: '#',
|
||||
screen: 'Home'
|
||||
screen: 'Home',
|
||||
},
|
||||
slots: {
|
||||
default: 'Home'
|
||||
}
|
||||
default: 'Home',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,9 +27,9 @@ import { ref } from 'vue'
|
|||
import { useRouter } from '@/composables'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
const props = withDefaults(defineProps<{ href?: string | undefined; screen?: ScreenName | undefined }>(), {
|
||||
const props = withDefaults(defineProps<{ href?: string | undefined, screen?: ScreenName | undefined }>(), {
|
||||
href: undefined,
|
||||
screen: undefined
|
||||
screen: undefined,
|
||||
})
|
||||
|
||||
const current = ref(false)
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
</ul>
|
||||
</SidebarSection>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faTools, faUpload, faUsers } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useAuthorization, useUpload } from '@/composables'
|
||||
|
|
|
@ -13,7 +13,7 @@ new class extends UnitTestCase {
|
|||
playlistStore.state.playlists = [
|
||||
factory.states('orphan')('playlist', { name: 'Foo Playlist' }),
|
||||
factory.states('orphan')('playlist', { name: 'Bar Playlist' }),
|
||||
factory.states('smart', 'orphan')('playlist', { name: 'Smart Playlist' })
|
||||
factory.states('smart', 'orphan')('playlist', { name: 'Smart Playlist' }),
|
||||
]
|
||||
|
||||
this.renderComponent()
|
||||
|
@ -26,7 +26,7 @@ new class extends UnitTestCase {
|
|||
it('displays playlist folders', () => {
|
||||
playlistFolderStore.state.folders = [
|
||||
factory('playlist-folder', { name: 'Foo Folder' }),
|
||||
factory('playlist-folder', { name: 'Bar Folder' })
|
||||
factory('playlist-folder', { name: 'Bar Folder' }),
|
||||
]
|
||||
|
||||
this.renderComponent()
|
||||
|
@ -39,9 +39,9 @@ new class extends UnitTestCase {
|
|||
global: {
|
||||
stubs: {
|
||||
PlaylistSidebarItem,
|
||||
PlaylistFolderSidebarItem
|
||||
}
|
||||
}
|
||||
PlaylistFolderSidebarItem,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,9 @@ const playlists = toRef(playlistStore.state, 'playlists')
|
|||
const favorites = toRef(favoriteStore.state, 'playables')
|
||||
|
||||
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
|
||||
if (folder_id === null) return true
|
||||
if (folder_id === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
// if the playlist's folder is not found, it's an orphan
|
||||
// this can happen if the playlist belongs to another user (collaborative playlist)
|
||||
|
|
|
@ -18,6 +18,6 @@ const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
|||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -39,9 +39,9 @@ new class extends UnitTestCase {
|
|||
return this.render(AboutKoelModel, {
|
||||
global: {
|
||||
stubs: {
|
||||
SponsorList: this.stub('sponsor-list')
|
||||
}
|
||||
}
|
||||
SponsorList: this.stub('sponsor-list'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,8 +41,8 @@
|
|||
<a href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a>
|
||||
and quite a few
|
||||
<a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a> <a
|
||||
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
|
||||
>contributors</a>.
|
||||
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
|
||||
>contributors</a>.
|
||||
</p>
|
||||
|
||||
<CreditsBlock v-if="isDemo" />
|
||||
|
@ -71,17 +71,18 @@ import Btn from '@/components/ui/form/Btn.vue'
|
|||
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
|
||||
import CreditsBlock from '@/components/meta/CreditsBlock.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const {
|
||||
shouldNotifyNewVersion,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
latestVersionReleaseUrl
|
||||
latestVersionReleaseUrl,
|
||||
} = useNewVersionNotification()
|
||||
|
||||
const { isPlus, license } = useKoelPlus()
|
||||
const { isAdmin } = useAuthorization()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const showPlusModal = () => {
|
||||
|
@ -89,7 +90,7 @@ const showPlusModal = () => {
|
|||
eventBus.emit('MODAL_SHOW_KOEL_PLUS')
|
||||
}
|
||||
|
||||
const isDemo = window.IS_DEMO;
|
||||
const isDemo = window.IS_DEMO
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
|
|
@ -11,7 +11,7 @@ new class extends UnitTestCase {
|
|||
const getMock = this.mock(http, 'get').mockResolvedValue([
|
||||
{ name: 'Foo', url: 'https://foo.com' },
|
||||
{ name: 'Bar', url: 'https://bar.com' },
|
||||
{ name: 'Something Else', url: 'https://something-else.net' }
|
||||
{ name: 'Something Else', url: 'https://something-else.net' },
|
||||
])
|
||||
|
||||
const { html } = this.render(CreditsBlock)
|
||||
|
|
|
@ -14,7 +14,7 @@ import { orderBy } from 'lodash'
|
|||
import { onMounted, ref } from 'vue'
|
||||
import { http } from '@/services'
|
||||
|
||||
type DemoCredits = {
|
||||
interface DemoCredits {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ onMounted(async () => {
|
|||
<style lang="postcss" scoped>
|
||||
li&:last-child {
|
||||
&::before {
|
||||
content: ', and '
|
||||
content: ', and ';
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
|
|
@ -9,7 +9,7 @@ new class extends UnitTestCase {
|
|||
protected beforeEach () {
|
||||
// Prevent actual HTTP requests from being made
|
||||
this.setReadOnlyProperty(http, 'silently', {
|
||||
patch: vi.fn()
|
||||
patch: vi.fn(),
|
||||
})
|
||||
|
||||
super.beforeEach(() => vi.useFakeTimers())
|
||||
|
|
|
@ -38,9 +38,15 @@ const stopBugging = () => {
|
|||
}
|
||||
|
||||
watch(preferenceStore.initialized, initialized => {
|
||||
if (!initialized) return
|
||||
if (preferenceStore.state.support_bar_no_bugging || isMobile.any) return
|
||||
if (isPlus.value) return
|
||||
if (!initialized) {
|
||||
return
|
||||
}
|
||||
if (preferenceStore.state.support_bar_no_bugging || isMobile.any) {
|
||||
return
|
||||
}
|
||||
if (isPlus.value) {
|
||||
return
|
||||
}
|
||||
|
||||
setUpShowBarTimeout()
|
||||
}, { immediate: true })
|
||||
|
|
|
@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
|
|||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { eventBus } from '@/utils'
|
||||
import { Events } from '@/config'
|
||||
import type { Events } from '@/config'
|
||||
|
||||
import CreateNewPlaylistContextMenu from './CreatePlaylistContextMenu.vue'
|
||||
|
||||
|
@ -11,7 +11,7 @@ new class extends UnitTestCase {
|
|||
it.each<[string, keyof Events]>([
|
||||
['playlist-context-menu-create-simple', 'MODAL_SHOW_CREATE_PLAYLIST_FORM'],
|
||||
['playlist-context-menu-create-smart', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'],
|
||||
['playlist-context-menu-create-folder', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM']
|
||||
['playlist-context-menu-create-folder', 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'],
|
||||
])('when clicking on %s, should emit %s', async (id, eventName) => {
|
||||
await this.renderComponent()
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { useContextMenu } from '@/composables'
|
||||
import { eventBus } from '@/utils'
|
||||
import { Events } from '@/config'
|
||||
import type { Events } from '@/config'
|
||||
|
||||
const { base, ContextMenu, open, trigger } = useContextMenu()
|
||||
|
||||
|
@ -20,7 +20,7 @@ type Action = 'new-playlist' | 'new-smart-playlist' | 'new-folder'
|
|||
const actionToEventMap: Record<Action, keyof Events> = {
|
||||
'new-playlist': 'MODAL_SHOW_CREATE_PLAYLIST_FORM',
|
||||
'new-smart-playlist': 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM',
|
||||
'new-folder': 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'
|
||||
'new-folder': 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM',
|
||||
}
|
||||
|
||||
const onItemClicked = (key: keyof typeof actionToEventMap) => trigger(() => eventBus.emit(actionToEventMap[key]))
|
||||
|
|
|
@ -18,7 +18,7 @@ const requestContextMenu = (e: MouseEvent) => {
|
|||
|
||||
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', {
|
||||
top: bottom,
|
||||
left: right
|
||||
left: right,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -32,13 +32,13 @@ import Btn from '@/components/ui/form/Btn.vue'
|
|||
import TextInput from '@/components/ui/form/TextInput.vue'
|
||||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
const name = ref('')
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const submit = async () => {
|
||||
|
|
|
@ -16,9 +16,9 @@ new class extends UnitTestCase {
|
|||
this.render(CreatePlaylistForm, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>ModalContextKey]: [ref({ folder })]
|
||||
}
|
||||
}
|
||||
[<symbol>ModalContextKey]: [ref({ folder })],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('from-playables')).toBeNull()
|
||||
|
@ -27,7 +27,7 @@ new class extends UnitTestCase {
|
|||
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
||||
folder_id: folder.id
|
||||
folder_id: folder.id,
|
||||
}, [])
|
||||
})
|
||||
|
||||
|
@ -39,9 +39,9 @@ new class extends UnitTestCase {
|
|||
this.render(CreatePlaylistForm, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>ModalContextKey]: [ref({ folder, playables })]
|
||||
}
|
||||
}
|
||||
[<symbol>ModalContextKey]: [ref({ folder, playables })],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
screen.getByText('from 3 songs')
|
||||
|
@ -50,7 +50,7 @@ new class extends UnitTestCase {
|
|||
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
||||
folder_id: folder.id
|
||||
folder_id: folder.id,
|
||||
}, playables)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ import TextInput from '@/components/ui/form/TextInput.vue'
|
|||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
import SelectBox from '@/components/ui/form/SelectBox.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
@ -56,7 +57,6 @@ const folderId = ref(targetFolder?.id)
|
|||
const name = ref('')
|
||||
const folders = toRef(playlistFolderStore.state, 'folders')
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const noun = computed(() => {
|
||||
|
@ -75,7 +75,7 @@ const submit = async () => {
|
|||
|
||||
try {
|
||||
const playlist = await playlistStore.store(name.value, {
|
||||
folder_id: folderId.value
|
||||
folder_id: folderId.value,
|
||||
}, playables)
|
||||
|
||||
close()
|
||||
|
|
|
@ -15,9 +15,9 @@ new class extends UnitTestCase {
|
|||
this.render(EditPlaylistFolderForm, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>ModalContextKey]: [ref({ folder })]
|
||||
}
|
||||
}
|
||||
[<symbol>ModalContextKey]: [ref({ folder })],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await this.type(screen.getByPlaceholderText('Folder name'), 'Your folder')
|
||||
|
|
|
@ -26,6 +26,7 @@ import Btn from '@/components/ui/form/Btn.vue'
|
|||
import TextInput from '@/components/ui/form/TextInput.vue'
|
||||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
@ -33,6 +34,8 @@ const folder = useModal().getFromContext<PlaylistFolder>('folder')
|
|||
|
||||
const name = ref(folder.name)
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
const submit = async () => {
|
||||
showOverlay()
|
||||
|
||||
|
@ -47,9 +50,6 @@ const submit = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const maybeClose = async () => {
|
||||
if (name.value.trim() === folder.name) {
|
||||
close()
|
||||
|
|
|
@ -14,7 +14,7 @@ new class extends UnitTestCase {
|
|||
|
||||
const playlist = factory('playlist', {
|
||||
name: 'My playlist',
|
||||
folder_id: playlistFolderStore.state.folders[0].id
|
||||
folder_id: playlistFolderStore.state.folders[0].id,
|
||||
})
|
||||
|
||||
playlistStore.state.playlists = [playlist]
|
||||
|
@ -24,9 +24,9 @@ new class extends UnitTestCase {
|
|||
this.render(EditPlaylistForm, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>ModalContextKey]: [ref({ playlist })]
|
||||
}
|
||||
}
|
||||
[<symbol>ModalContextKey]: [ref({ playlist })],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await this.type(screen.getByPlaceholderText('Playlist name'), 'Your playlist')
|
||||
|
@ -35,7 +35,7 @@ new class extends UnitTestCase {
|
|||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith(playlist, {
|
||||
name: 'Your playlist',
|
||||
folder_id: playlist.folder_id
|
||||
folder_id: playlist.folder_id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -44,6 +44,7 @@ import TextInput from '@/components/ui/form/TextInput.vue'
|
|||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
import SelectBox from '@/components/ui/form/SelectBox.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
@ -53,7 +54,6 @@ const name = ref(playlist.name)
|
|||
const folderId = ref(playlist.folder_id)
|
||||
const folders = toRef(playlistFolderStore.state, 'folders')
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const submit = async () => {
|
||||
|
@ -62,7 +62,7 @@ const submit = async () => {
|
|||
try {
|
||||
await playlistStore.update(playlist, {
|
||||
name: name.value,
|
||||
folder_id: folderId.value
|
||||
folder_id: folderId.value,
|
||||
})
|
||||
|
||||
toastSuccess('Playlist updated.')
|
||||
|
@ -92,6 +92,6 @@ form {
|
|||
}
|
||||
|
||||
label.folder {
|
||||
flex: .6;
|
||||
flex: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -13,8 +13,8 @@ new class extends UnitTestCase {
|
|||
|
||||
this.render(Component, {
|
||||
props: {
|
||||
playlist
|
||||
}
|
||||
playlist,
|
||||
},
|
||||
})
|
||||
|
||||
await this.user.click(screen.getByText('Invite'))
|
||||
|
|
|
@ -11,13 +11,13 @@ new class extends UnitTestCase {
|
|||
const { html } = this.render(Modal, {
|
||||
global: {
|
||||
provide: {
|
||||
[<symbol>ModalContextKey]: [ref({ playlist: factory('playlist') })]
|
||||
[<symbol>ModalContextKey]: [ref({ playlist: factory('playlist') })],
|
||||
},
|
||||
stubs: {
|
||||
InviteCollaborators: this.stub('InviteCollaborators'),
|
||||
CollaboratorList: this.stub('CollaboratorList')
|
||||
}
|
||||
}
|
||||
CollaboratorList: this.stub('CollaboratorList'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(html()).toMatchSnapshot()
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<div
|
||||
class="collaboration-modal max-w-[640px]"
|
||||
tabindex="0"
|
||||
@keydown.esc="close"
|
||||
data-testid="playlist-collaboration"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<header>
|
||||
<h1>Playlist Collaboration</h1>
|
||||
|
@ -41,12 +41,12 @@ import Btn from '@/components/ui/form/Btn.vue'
|
|||
import InviteCollaborators from '@/components/playlist/InvitePlaylistCollaborators.vue'
|
||||
import CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const playlist = useModal().getFromContext<Playlist>('playlist')
|
||||
const { currentUser } = useAuthorization()
|
||||
|
||||
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
</script>
|
||||
|
||||
|
|
|
@ -8,11 +8,11 @@ new class extends UnitTestCase {
|
|||
protected test () {
|
||||
it('renders', async () => {
|
||||
const playlist = factory('playlist', {
|
||||
is_collaborative: true
|
||||
is_collaborative: true,
|
||||
})
|
||||
|
||||
const fetchMock = this.mock(playlistCollaborationService, 'fetchCollaborators').mockResolvedValue(
|
||||
factory('playlist-collaborator', 5)
|
||||
factory('playlist-collaborator', 5),
|
||||
)
|
||||
|
||||
const { html } = await this.be().renderComponent(playlist)
|
||||
|
@ -24,13 +24,13 @@ new class extends UnitTestCase {
|
|||
private async renderComponent (playlist: Playlist) {
|
||||
const rendered = this.render(Component, {
|
||||
props: {
|
||||
playlist
|
||||
playlist,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ListItem: this.stub('ListItem')
|
||||
}
|
||||
}
|
||||
ListItem: this.stub('ListItem'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await this.tick(2)
|
||||
|
|
|
@ -15,7 +15,8 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { sortBy } from 'lodash'
|
||||
import { computed, onMounted, ref, Ref, toRefs } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, ref, toRefs } from 'vue'
|
||||
import { useAuthorization, useDialogBox, useErrorHandler } from '@/composables'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
|
@ -29,7 +30,7 @@ const { playlist } = toRefs(props)
|
|||
const { currentUser } = useAuthorization()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
const collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const currentUserIsOwner = computed(() => currentUser.value?.id === playlist.value.user_id)
|
||||
|
@ -41,10 +42,14 @@ const fetchCollaborators = async () => {
|
|||
collaborators.value = sortBy(
|
||||
await playlistCollaborationService.fetchCollaborators(playlist.value),
|
||||
({ id }) => {
|
||||
if (id === currentUser.value.id) return 0
|
||||
if (id === playlist.value.user_id) return 1
|
||||
if (id === currentUser.value.id) {
|
||||
return 0
|
||||
}
|
||||
if (id === playlist.value.user_id) {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
@ -53,10 +58,12 @@ const fetchCollaborators = async () => {
|
|||
|
||||
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
|
||||
const deadSure = await showConfirmDialog(
|
||||
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
|
||||
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`,
|
||||
)
|
||||
|
||||
if (!deadSure) return
|
||||
if (!deadSure) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)
|
||||
|
|
|
@ -12,7 +12,7 @@ new class extends UnitTestCase {
|
|||
collaborator: factory('playlist-collaborator', { id: currentUser.id + 1 }),
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
role: 'owner',
|
||||
})
|
||||
|
||||
expect(screen.queryByTitle('This is you!')).toBeNull()
|
||||
|
@ -21,16 +21,14 @@ new class extends UnitTestCase {
|
|||
it('shows a badge when current user is the collaborator', async () => {
|
||||
const currentUser = factory('user')
|
||||
this.be(currentUser).renderComponent({
|
||||
collaborator: factory('playlist-collaborator',
|
||||
{
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
avatar: currentUser.avatar
|
||||
}
|
||||
),
|
||||
collaborator: factory('playlist-collaborator', {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
avatar: currentUser.avatar,
|
||||
}),
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
role: 'owner',
|
||||
})
|
||||
|
||||
screen.getByTitle('This is you!')
|
||||
|
@ -43,7 +41,7 @@ new class extends UnitTestCase {
|
|||
collaborator,
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
role: 'owner',
|
||||
})
|
||||
|
||||
screen.getByText('Owner')
|
||||
|
@ -52,7 +50,7 @@ new class extends UnitTestCase {
|
|||
collaborator,
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'contributor'
|
||||
role: 'contributor',
|
||||
})
|
||||
|
||||
screen.getByText('Contributor')
|
||||
|
@ -64,7 +62,7 @@ new class extends UnitTestCase {
|
|||
collaborator,
|
||||
removable: true,
|
||||
manageable: true,
|
||||
role: 'owner'
|
||||
role: 'owner',
|
||||
})
|
||||
|
||||
await this.user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
|
@ -74,18 +72,18 @@ new class extends UnitTestCase {
|
|||
}
|
||||
|
||||
private renderComponent (props: {
|
||||
collaborator: PlaylistCollaborator,
|
||||
removable: boolean,
|
||||
manageable: boolean,
|
||||
collaborator: PlaylistCollaborator
|
||||
removable: boolean
|
||||
manageable: boolean
|
||||
role: 'owner' | 'contributor'
|
||||
}) {
|
||||
return this.render(Component, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
UserAvatar: this.stub('UserAvatar')
|
||||
}
|
||||
}
|
||||
UserAvatar: this.stub('UserAvatar'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,16 +34,15 @@ import UserAvatar from '@/components/user/UserAvatar.vue'
|
|||
import { useAuthorization } from '@/composables'
|
||||
|
||||
const props = defineProps<{
|
||||
collaborator: PlaylistCollaborator,
|
||||
removable: boolean,
|
||||
manageable: boolean,
|
||||
collaborator: PlaylistCollaborator
|
||||
removable: boolean
|
||||
manageable: boolean
|
||||
role: 'owner' | 'contributor'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'remove'): void }>()
|
||||
const { collaborator, removable, role } = toRefs(props)
|
||||
const { currentUser } = useAuthorization()
|
||||
|
||||
const emit = defineEmits<{ (e: 'remove'): void }>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
|
|
@ -133,7 +133,7 @@ new class extends UnitTestCase {
|
|||
it('does not have an option to edit or delete if the playlist is not owned by the current user', async () => {
|
||||
const user = factory('user')
|
||||
const playlist = factory('playlist', {
|
||||
user_id: user.id + 1
|
||||
user_id: user.id + 1,
|
||||
})
|
||||
|
||||
await this.renderComponent(playlist, user)
|
||||
|
@ -156,7 +156,7 @@ new class extends UnitTestCase {
|
|||
|
||||
private async renderComponent (playlist: Playlist, user: User | null = null) {
|
||||
userStore.state.current = user || factory('user', {
|
||||
id: playlist.user_id
|
||||
id: playlist.user_id,
|
||||
})
|
||||
|
||||
this.render(PlaylistContextMenu)
|
||||
|
|
|
@ -62,7 +62,7 @@ import {
|
|||
useModal,
|
||||
useOverlay,
|
||||
useRouter,
|
||||
useSmartPlaylistForm
|
||||
useSmartPlaylistForm,
|
||||
} from '@/composables'
|
||||
|
||||
import CheckBox from '@/components/ui/form/CheckBox.vue'
|
||||
|
@ -70,13 +70,15 @@ import TextInput from '@/components/ui/form/TextInput.vue'
|
|||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
import SelectBox from '@/components/ui/form/SelectBox.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const {
|
||||
Btn,
|
||||
FormBase,
|
||||
RuleGroup,
|
||||
collectedRuleGroups,
|
||||
addGroup,
|
||||
onGroupChanged
|
||||
onGroupChanged,
|
||||
} = useSmartPlaylistForm()
|
||||
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
|
@ -92,7 +94,6 @@ const folderId = ref(targetFolder?.id)
|
|||
const folders = toRef(playlistFolderStore.state, 'folders')
|
||||
const ownSongsOnly = ref(false)
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const isPristine = () => name.value === ''
|
||||
|
@ -115,7 +116,7 @@ const submit = async () => {
|
|||
const playlist = await playlistStore.store(name.value, {
|
||||
rules: collectedRuleGroups.value,
|
||||
folder_id: folderId.value,
|
||||
own_songs_only: ownSongsOnly.value
|
||||
own_songs_only: ownSongsOnly.value,
|
||||
})
|
||||
|
||||
close()
|
||||
|
|
|
@ -68,13 +68,14 @@ import {
|
|||
useMessageToaster,
|
||||
useModal,
|
||||
useOverlay,
|
||||
useSmartPlaylistForm
|
||||
useSmartPlaylistForm,
|
||||
} from '@/composables'
|
||||
import CheckBox from '@/components/ui/form/CheckBox.vue'
|
||||
import TextInput from '@/components/ui/form/TextInput.vue'
|
||||
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||
import SelectBox from '@/components/ui/form/SelectBox.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
@ -94,10 +95,9 @@ const {
|
|||
RuleGroup,
|
||||
collectedRuleGroups,
|
||||
addGroup,
|
||||
onGroupChanged
|
||||
onGroupChanged,
|
||||
} = useSmartPlaylistForm(mutablePlaylist.rules)
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const maybeClose = async () => {
|
||||
|
|
|
@ -47,9 +47,15 @@ import FormRow from '@/components/ui/form/FormRow.vue'
|
|||
import SelectBox from '@/components/ui/form/SelectBox.vue'
|
||||
import Btn from '@/components/ui/form/Btn.vue'
|
||||
|
||||
const props = defineProps<{ rule: SmartPlaylistRule }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'input', rule: SmartPlaylistRule): void
|
||||
(e: 'remove'): void
|
||||
}>()
|
||||
|
||||
const RuleInput = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/SmartPlaylistRuleInput.vue'))
|
||||
|
||||
const props = defineProps<{ rule: SmartPlaylistRule }>()
|
||||
const { rule } = toRefs(props)
|
||||
|
||||
const mutatedRule = Object.assign({}, rule.value) as SmartPlaylistRule
|
||||
|
@ -78,8 +84,8 @@ if (!operator) {
|
|||
selectedOperator.value = operator
|
||||
|
||||
const isOriginalOperatorSelected = computed(() => {
|
||||
return selectedModel.value?.name === mutatedRule.model.name &&
|
||||
selectedOperator.value?.operator === mutatedRule.operator
|
||||
return selectedModel.value?.name === mutatedRule.model.name
|
||||
&& selectedOperator.value?.operator === mutatedRule.operator
|
||||
})
|
||||
|
||||
const availableInputs = computed<{ id: string, value: any }[]>(() => {
|
||||
|
@ -92,7 +98,7 @@ const availableInputs = computed<{ id: string, value: any }[]>(() => {
|
|||
for (let i = 0, inputCount = selectedOperator.value.inputs || 1; i < inputCount; ++i) {
|
||||
inputs.push({
|
||||
id: `${mutatedRule.model.name}_${selectedOperator.value.operator}_${i}`,
|
||||
value: isOriginalOperatorSelected.value ? mutatedRule.value[i] : ''
|
||||
value: isOriginalOperatorSelected.value ? mutatedRule.value[i] : '',
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -109,17 +115,12 @@ watch(availableOperators, () => {
|
|||
|
||||
const valueSuffix = computed(() => selectedOperator.value?.unit || selectedModel.value?.unit)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'input', rule: SmartPlaylistRule): void,
|
||||
(e: 'remove'): void
|
||||
}>()
|
||||
|
||||
const onInput = () => {
|
||||
emit('input', {
|
||||
id: mutatedRule.id,
|
||||
model: selectedModel.value!,
|
||||
operator: selectedOperator.value?.operator!,
|
||||
value: availableInputs.value.map(input => input.value)
|
||||
operator: selectedOperator.value!.operator,
|
||||
value: availableInputs.value.map(input => input.value),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue