Migrate pagination to v-model and start moving away from mixins

environments/review-front-deve-otr6gc/deployments/13419
Kasper Seweryn 2022-05-06 19:41:36 +00:00 zatwierdzone przez Georg Krause
rodzic a25f1bbb1f
commit 3ab0435f27
7 zmienionych plików z 267 dodań i 361 usunięć

Wyświetl plik

@ -1,3 +1,66 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { range, clamp } from 'lodash-es'
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'
interface Props {
current?: number
paginateBy?: number
total: number,
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
current: 1,
paginateBy: 25,
compact: false
})
const emit = defineEmits(['update:current', 'pageChanged'])
const current = useVModel(props, 'current', emit)
const RANGE = 2
const pages = computed(() => {
const start = range(1, 1 + RANGE)
const end = range(maxPage.value - RANGE, maxPage.value)
const middle = range(
clamp(props.current - RANGE + 1, 1, maxPage.value),
clamp(props.current + RANGE, 1, maxPage.value)
).filter(i => !start.includes(i) && !end.includes(i))
console.log(middle, end)
return [
...start,
middle.length === 0 && 'skip',
middle.length !== 0 && start[start.length - 1] + 1 !== middle[0] && 'skip',
...middle,
middle.length !== 0 && middle[middle.length - 1] + 1 !== end[0] && 'skip',
...end
].filter(i => i !== false) as Array<'skip' | number>
})
const maxPage = computed(() => Math.ceil(props.total / props.paginateBy))
const setPage = (page: number) => {
if (page > maxPage.value || page < 1) {
return
}
current.value = page
// TODO (wvffle): Compat before change to v-model
emit('pageChanged', page)
}
const { $pgettext } = useGettext()
const labels = computed(() => ({
pagination: $pgettext('Content/*/Hidden text/Noun', 'Pagination'),
previousPage: $pgettext('Content/*/Link', 'Previous Page'),
nextPage: $pgettext('Content/*/Link', 'Next Page')
}))
</script>
<template>
<div
v-if="maxPage > 1"
@ -6,107 +69,38 @@
:aria-label="labels.pagination"
>
<a
href
href="#"
:disabled="current - 1 < 1 || null"
role="button"
:aria-label="labels.previousPage"
:class="[{'disabled': current - 1 < 1}, 'item']"
@click.prevent.stop="selectPage(current - 1)"
><i class="angle left icon" /></a>
:class="[{ 'disabled': current - 1 < 1 }, 'item']"
@click.prevent.stop="setPage(current - 1)"
>
<i class="angle left icon" />
</a>
<template v-if="!compact">
<a
v-for="page in pages"
:key="page"
href
:class="[{'active': page === current}, {'disabled': page === 'skip'}, 'item']"
@click.prevent.stop="selectPage(page)"
href="#"
:class="[{ active: page === current, disabled: page === 'skip' }, 'item']"
@click.prevent.stop="page !== 'skip' && setPage(page)"
>
<span v-if="page !== 'skip'">{{ page }}</span>
<span v-else></span>
</a>
</template>
<a
href
href="#"
:disabled="current + 1 > maxPage || null"
role="button"
:aria-label="labels.nextPage"
:class="[{'disabled': current + 1 > maxPage}, 'item']"
@click.prevent.stop="selectPage(current + 1)"
><i class="angle right icon" /></a>
:class="[{ disabled: current + 1 > maxPage }, 'item']"
@click.prevent.stop="setPage(current + 1)"
>
<i class="angle right icon" />
</a>
</div>
</template>
<script>
import { range as lodashRange, sortBy, uniq } from 'lodash-es'
export default {
props: {
current: { type: Number, default: 1 },
paginateBy: { type: Number, default: 25 },
total: { type: Number, required: true },
compact: { type: Boolean, default: false }
},
computed: {
labels () {
return {
pagination: this.$pgettext('Content/*/Hidden text/Noun', 'Pagination'),
previousPage: this.$pgettext('Content/*/Link', 'Previous Page'),
nextPage: this.$pgettext('Content/*/Link', 'Next Page')
}
},
pages: function () {
const range = 2
const current = this.current
const beginning = lodashRange(1, Math.min(this.maxPage, 1 + range))
const middle = lodashRange(
Math.max(1, current - range + 1),
Math.min(this.maxPage, current + range)
)
const end = lodashRange(this.maxPage, Math.max(1, this.maxPage - range))
let allowed = beginning.concat(middle, end)
allowed = uniq(allowed)
allowed = sortBy(allowed, [
e => {
return e
}
])
const final = []
allowed.forEach(p => {
const last = final.slice(-1)[0]
let consecutive = true
if (last === 'skip') {
consecutive = false
} else {
if (!last) {
consecutive = true
} else {
consecutive = last + 1 === p
}
}
if (consecutive) {
final.push(p)
} else {
if (p !== 'skip') {
final.push('skip')
final.push(p)
}
}
})
return final
},
maxPage: function () {
return Math.ceil(this.total / this.paginateBy)
}
},
methods: {
selectPage: function (page) {
if (page > this.maxPage || page < 1) {
return
}
if (this.current !== page) {
this.$emit('page-changed', page)
}
}
}
}
</script>

Wyświetl plik

@ -196,6 +196,7 @@
<tr>
<th v-if="actions.length > 0">
<div class="ui checkbox">
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
<input
type="checkbox"
:aria-label="labels.selectAllItems"
@ -217,6 +218,7 @@
v-if="actions.length > 0"
class="collapsing"
>
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
<input
type="checkbox"
:aria-label="labels.selectItem"

Wyświetl plik

@ -1,245 +1,195 @@
<script setup lang="ts">
import axios from 'axios'
import $ from 'jquery'
import RadioButton from '~/components/radios/Button.vue'
import Pagination from '~/components/Pagination.vue'
import { checkRedirectToLogin } from '~/utils'
import TrackTable from '~/components/audio/track/Table.vue'
import useLogger from '~/composables/useLogger'
import useSharedLabels from '~/composables/locale/useSharedLabels'
import useOrdering from '~/composables/useOrdering'
import { onBeforeRouteUpdate, useRouter } from 'vue-router'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useStore } from '~/store'
import { Track } from '~/types'
import { useGettext } from 'vue3-gettext'
import { OrderingField, RouteWithPreferences } from '~/store/ui'
interface Props {
orderingConfigName: RouteWithPreferences | null
defaultPage?: number,
defaultPaginateBy?: number
}
const props = withDefaults(defineProps<Props>(), {
defaultPage: 1,
defaultPaginateBy: 1
})
const store = useStore()
await checkRedirectToLogin(store, useRouter())
// TODO (wvffle): Make sure everything is it's own type
const page = ref(+props.defaultPage)
const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
['creation_date', 'creation_date'],
['title', 'track_title'],
['album__title', 'album_title'],
['artist__name', 'artist_name']
]
const logger = useLogger()
const sharedLabels = useSharedLabels()
const router = useRouter()
const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props.orderingConfigName)
const updateQueryString = () => router.replace({
query: {
page: page.value,
paginateBy: paginateBy.value,
ordering: orderingString.value
}
})
const results = reactive<Track[]>([])
const nextLink = ref()
const previousLink = ref()
const count = ref(0)
const isLoading = ref(false)
const fetchFavorites = async () => {
isLoading.value = true
const params = {
favorites: 'true',
page: page.value,
page_size: paginateBy.value,
ordering: orderingString.value
}
try {
logger.time('Loading user favorites')
const response = await axios.get('tracks/', { params: params })
results.length = 0
results.push(...response.data.results)
for (const track of results) {
store.commit('favorites/track', { id: track.id, value: true })
}
count.value = response.data.count
nextLink.value = response.data.next
previousLink.value = response.data.previous
} catch (error) {
// TODO (wvffle): Handle error
} finally {
logger.timeEnd('Loading user favorites')
isLoading.value = false
}
}
watch(page, updateQueryString)
onOrderingUpdate(updateQueryString)
onBeforeRouteUpdate(fetchFavorites)
fetchFavorites()
// @ts-expect-error semantic ui
onMounted(() => $('.ui.dropdown').dropdown())
const { $pgettext } = useGettext()
const labels = computed(() => ({
title: $pgettext('Head/Favorites/Title', 'Your Favorites')
}))
</script>
<template>
<main
v-title="labels.title"
class="main pusher"
>
<main v-title="labels.title" class="main pusher">
<section class="ui vertical center aligned stripe segment">
<div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
<div :class="['ui', { 'active': isLoading }, 'inverted', 'dimmer']">
<div class="ui text loader">
<translate translate-context="Content/Favorites/Message">
Loading your favorites
</translate>
<translate translate-context="Content/Favorites/Message">Loading your favorites</translate>
</div>
</div>
<h2
v-if="results"
class="ui center aligned icon header"
>
<h2 v-if="results" class="ui center aligned icon header">
<i class="circular inverted heart pink icon" />
<translate
translate-plural="%{ count } favorites"
:translate-n="$store.state.favorites.count"
:translate-params="{count: results.count}"
:translate-params="{ count }"
translate-context="Content/Favorites/Title"
>
%{ count } favorite
</translate>
>%{ count } favorite</translate>
</h2>
<radio-button
v-if="hasFavorites"
type="favorites"
/>
<radio-button v-if="$store.state.favorites.count > 0" type="favorites" />
</section>
<section
v-if="hasFavorites"
class="ui vertical stripe segment"
>
<div :class="['ui', {'loading': isLoading}, 'form']">
<section v-if="$store.state.favorites.count > 0" class="ui vertical stripe segment">
<div :class="['ui', { 'loading': isLoading }, 'form']">
<div class="fields">
<div class="field">
<label for="favorites-ordering"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select
id="favorites-ordering"
v-model="ordering"
class="ui dropdown"
>
<label for="favorites-ordering">
<translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate>
</label>
<select id="favorites-ordering" v-model="ordering" class="ui dropdown">
<option
v-for="option in orderingOptions"
:key="option[0]"
:value="option[0]"
>
{{ sharedLabels.filters[option[1]] }}
</option>
>{{ sharedLabels.filters[option[1]] }}</option>
</select>
</div>
<div class="field">
<label for="favorites-ordering-direction"><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label>
<label for="favorites-ordering-direction">
<translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate>
</label>
<select
id="favorites-ordering-direction"
v-model="orderingDirection"
class="ui dropdown"
>
<option value="+">
<translate translate-context="Content/Search/Dropdown">
Ascending
</translate>
<translate translate-context="Content/Search/Dropdown">Ascending</translate>
</option>
<option value="-">
<translate translate-context="Content/Search/Dropdown">
Descending
</translate>
<translate translate-context="Content/Search/Dropdown">Descending</translate>
</option>
</select>
</div>
<div class="field">
<label for="favorites-results"><translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate></label>
<select
id="favorites-results"
v-model="paginateBy"
class="ui dropdown"
>
<option :value="parseInt(12)">
12
</option>
<option :value="parseInt(25)">
25
</option>
<option :value="parseInt(50)">
50
</option>
<label for="favorites-results">
<translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate>
</label>
<select id="favorites-results" v-model="paginateBy" class="ui dropdown">
<option :value="12">12</option>
<option :value="25">25</option>
<option :value="50">50</option>
</select>
</div>
</div>
</div>
<track-table
v-if="results"
:show-artist="true"
:show-album="true"
:tracks="results.results"
/>
<track-table v-if="results" :show-artist="true" :show-album="true" :tracks="results" />
<div class="ui center aligned basic segment">
<pagination
v-if="results && results.count > paginateBy"
:current="page"
v-if="results && count > paginateBy"
v-model:current="page"
:paginate-by="paginateBy"
:total="results.count"
@page-changed="selectPage"
:total="count"
/>
</div>
</section>
<div
v-else
class="ui placeholder segment"
>
<div v-else class="ui placeholder segment">
<div class="ui icon header">
<i class="broken heart icon" />
<translate
translate-context="Content/Home/Placeholder"
>
No tracks have been added to your favorites yet
</translate>
>No tracks have been added to your favorites yet</translate>
</div>
<router-link
:to="'/library'"
class="ui success labeled icon button"
>
<router-link :to="'/library'" class="ui success labeled icon button">
<i class="headphones icon" />
<translate translate-context="Content/*/Verb">
Browse the library
</translate>
<translate translate-context="Content/*/Verb">Browse the library</translate>
</router-link>
</div>
</main>
</template>
<script>
import axios from 'axios'
import $ from 'jquery'
import RadioButton from '~/components/radios/Button.vue'
import Pagination from '~/components/Pagination.vue'
import OrderingMixin from '~/components/mixins/Ordering.vue'
import PaginationMixin from '~/components/mixins/Pagination.vue'
import { checkRedirectToLogin } from '~/utils'
import TrackTable from '~/components/audio/track/Table.vue'
import useLogger from '~/composables/useLogger'
import useSharedLabels from '~/composables/locale/useSharedLabels'
const logger = useLogger()
const FAVORITES_URL = 'tracks/'
export default {
components: {
RadioButton,
Pagination,
TrackTable
},
mixins: [OrderingMixin, PaginationMixin],
setup () {
const sharedLabels = useSharedLabels()
return { sharedLabels }
},
data () {
return {
results: null,
isLoading: false,
nextLink: null,
previousLink: null,
page: parseInt(this.defaultPage),
orderingOptions: [
['creation_date', 'creation_date'],
['title', 'track_title'],
['album__title', 'album_title'],
['artist__name', 'artist_name']
]
}
},
computed: {
labels () {
return {
title: this.$pgettext('Head/Favorites/Title', 'Your Favorites')
}
},
hasFavorites () {
return this.$store.state.favorites.count > 0
}
},
watch: {
page: function () {
this.updateQueryString()
},
paginateBy: function () {
this.updateQueryString()
},
orderingDirection: function () {
this.updateQueryString()
},
ordering: function () {
this.updateQueryString()
}
},
async created () {
await checkRedirectToLogin(this.$store, this.$router)
this.fetchFavorites(FAVORITES_URL)
},
mounted () {
$('.ui.dropdown').dropdown()
},
methods: {
updateQueryString: function () {
this.$router.replace({
query: {
page: this.page,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}
})
this.fetchFavorites(FAVORITES_URL)
},
fetchFavorites (url) {
const self = this
this.isLoading = true
const params = {
favorites: 'true',
page: this.page,
page_size: this.paginateBy,
ordering: this.getOrderingAsString()
}
logger.time('Loading user favorites')
axios.get(url, { params: params }).then(response => {
self.results = response.data
self.nextLink = response.data.next
self.previousLink = response.data.previous
self.results.results.forEach(track => {
self.$store.commit('favorites/track', { id: track.id, value: true })
})
logger.timeEnd('Loading user favorites')
self.isLoading = false
})
},
selectPage: function (page) {
this.page = page
}
}
}
</script>

Wyświetl plik

@ -1,69 +0,0 @@
<script>
export default {
props: {
defaultOrdering: { type: String, required: false, default: '' },
orderingConfigName: { type: String, required: false, default: '' }
},
computed: {
orderingConfig () {
return this.$store.state.ui.routePreferences[this.orderingConfigName || this.$route.name]
},
paginateBy: {
set (paginateBy) {
this.$store.commit('ui/paginateBy', {
route: this.$route.name,
value: paginateBy
})
},
get () {
return this.orderingConfig.paginateBy
}
},
ordering: {
set (ordering) {
this.$store.commit('ui/ordering', {
route: this.$route.name,
value: ordering
})
},
get () {
return this.orderingConfig.ordering
}
},
orderingDirection: {
set (orderingDirection) {
this.$store.commit('ui/orderingDirection', {
route: this.$route.name,
value: orderingDirection
})
},
get () {
return this.orderingConfig.orderingDirection
}
}
},
methods: {
getOrderingFromString (s) {
const parts = s.split('-')
if (parts.length > 1) {
return {
direction: '-',
field: parts.slice(1).join('-')
}
} else {
return {
direction: '+',
field: s
}
}
},
getOrderingAsString () {
let direction = this.orderingDirection
if (direction === '+') {
direction = ''
}
return [direction, this.ordering].join('')
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,38 @@
import { MaybeRef, reactiveComputed, toRefs } from '@vueuse/core'
import { computed, unref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from '~/store'
import { OrderingDirection, OrderingField, RouteWithPreferences } from '~/store/ui'
export default (orderingConfigName: MaybeRef<RouteWithPreferences | null>) => {
const store = useStore()
const route = useRoute()
const config = reactiveComputed(() => {
const name = unref(orderingConfigName) ?? route.name as RouteWithPreferences
return store.state.ui.routePreferences[name]
})
const { paginateBy, ordering, orderingDirection } = toRefs(config)
const orderingString = computed(() => {
if (orderingDirection.value === '-') return `-${ordering.value}`
return ordering.value
})
const getOrderingFromString = (str: string) => ({
direction: (str[0] === '-' ? '-' : '+') as OrderingDirection,
field: (str[0] === '-' || str[0] === '+' ? str.slice(1) : str) as OrderingField
})
const onOrderingUpdate = (fn: () => void) => watch(config, fn)
return {
paginateBy,
ordering,
orderingDirection,
orderingString,
getOrderingFromString,
onOrderingUpdate
}
}

Wyświetl plik

@ -50,6 +50,7 @@ Promise.all(modules).finally(() => {
logger.info('Everything loaded!')
})
// TODO (wvffle): Rename filters from useSharedLabels to filters from backend
// TODO (wvffle): Check for mixin merging: https://v3-migration.vuejs.org/breaking-changes/data-option.html#mixin-merge-behavior-change=
// TODO (wvffle): Use emits options: https://v3-migration.vuejs.org/breaking-changes/emits-option.html
// TODO (wvffle): Find all array watchers and make them deep

Wyświetl plik

@ -6,7 +6,7 @@ import { availableLanguages } from '~/init/locale'
type SupportedExtension = 'flac' | 'ogg' | 'mp3' | 'opus' | 'aac' | 'm4a' | 'aiff' | 'aif'
type RouteWithPreferences = 'library.artists.browse' | 'library.podcasts.browse' | 'library.radios.browse'
export type RouteWithPreferences = 'library.artists.browse' | 'library.podcasts.browse' | 'library.radios.browse'
| 'library.playlists.browse' | 'library.albums.me' | 'library.artists.me' | 'library.radios.me'
| 'library.playlists.me' | 'content.libraries.files' | 'library.detail.upload' | 'library.detail.edit'
| 'library.detail' | 'favorites' | 'manage.channels' | 'manage.library.tags' | 'manage.library.uploads'
@ -18,12 +18,12 @@ type RouteWithPreferences = 'library.artists.browse' | 'library.podcasts.browse'
export type WebSocketEventName = 'inbox.item_added' | 'import.status_updated' | 'mutation.created' | 'mutation.updated'
| 'report.created' | 'user_request.created' | 'Listen'
type Ordering = 'creation_date'
type OrderingDirection = '-'
export type OrderingField = 'creation_date' | 'title' | 'album__title' | 'artist__name'
export type OrderingDirection = '-' | '+'
interface RoutePreferences {
paginateBy: number
orderingDirection: OrderingDirection
ordering: Ordering
ordering: OrderingField
}
interface WebSocketEvent {
@ -351,16 +351,6 @@ const store: Module<State, RootState> = {
pageTitle: (state, value) => {
state.pageTitle = value
},
paginateBy: (state, { route, value }: { route: RouteWithPreferences, value: number }) => {
state.routePreferences[route].paginateBy = value
},
ordering: (state, { route, value }: { route: RouteWithPreferences, value: Ordering }) => {
state.routePreferences[route].ordering = value
},
orderingDirection: (state, { route, value }: { route: RouteWithPreferences, value: OrderingDirection }) => {
state.routePreferences[route].orderingDirection = value
},
window: (state, value) => {
state.window = value
}