feat: port label store to pinia | pinia 1/9 (#2391)

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2391
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2022-09-21 14:23:57 +00:00 committed by konrad
parent e91b5fde02
commit d67e5e386d
17 changed files with 276 additions and 235 deletions

@ -46,6 +46,7 @@
"lodash.debounce": "4.0.8",
"marked": "4.1.0",
"minimist": "1.2.6",
"pinia": "^2.0.21",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"ufo": "0.8.5",

@ -66,6 +66,7 @@ import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import {useLabelStore} from '@/stores/labels'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
@ -197,7 +198,8 @@ function useRenewTokenOnFocus() {
}
useRenewTokenOnFocus()
store.dispatch('labels/loadAllLabels')
const labelStore = useLabelStore()
labelStore.loadAllLabels()
</script>
<style lang="scss" scoped>

@ -190,6 +190,8 @@
<script lang="ts">
import {defineComponent} from 'vue'
import {useLabelStore} from '@/stores/labels'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
@ -307,8 +309,10 @@ export default defineComponent({
this.change()
},
},
foundLabels() {
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
const labelStore = useLabelStore()
return labelStore.filterLabelsByQuery(this.labels, this.labelQuery)
},
},
methods: {
@ -336,7 +340,8 @@ export default defineComponent({
: ''
const labelIds = labels.split(',').map(i => parseInt(i))
this.labels = this.$store.getters['labels/getLabelsByIds'](labelIds)
const labelStore = useLabelStore()
this.labels = labelStore.getLabelsByIds(labelIds)
},
removePropertyFromFilter(propertyName) {
// Because of the way arrays work, we can only ever remove one element at once.

@ -50,6 +50,7 @@ import {success} from '@/message'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import type { ILabel } from '@/modelTypes/ILabel'
import { useLabelStore } from '@/stores/labels'
const props = defineProps({
modelValue: {
@ -86,8 +87,10 @@ watch(
},
)
const foundLabels = computed(() => store.getters['labels/filterLabelsByQuery'](labels.value, query.value))
const loading = computed(() => labelTaskService.loading || (store.state.loading && store.state.loadingModule === 'labels'))
const labelStore = useLabelStore()
const foundLabels = computed(() => labelStore.filterLabelsByQuery(labels.value, query.value))
const loading = computed(() => labelTaskService.loading || labelStore.isLoading)
function findLabel(newQuery: string) {
query.value = newQuery
@ -129,7 +132,8 @@ async function createAndAddLabel(title: string) {
return
}
const newLabel = await store.dispatch('labels/createLabel', new LabelModel({title}))
const labelStore = useLabelStore()
const newLabel = await labelStore.createLabel(new LabelModel({title}))
addLabel(newLabel, false)
labels.value.push(newLabel)
success({message: t('task.label.addCreateSuccess')})

@ -1,47 +0,0 @@
import {describe, it, expect} from 'vitest'
import {filterLabelsByQuery} from './labels'
import {createNewIndexer} from '../indexes'
const {add} = createNewIndexer('labels', ['title', 'description'])
describe('filter labels', () => {
const state = {
labels: {
1: {id: 1, title: 'label1'},
2: {id: 2, title: 'label2'},
3: {id: 3, title: 'label3'},
4: {id: 4, title: 'label4'},
5: {id: 5, title: 'label5'},
6: {id: 6, title: 'label6'},
7: {id: 7, title: 'label7'},
8: {id: 8, title: 'label8'},
9: {id: 9, title: 'label9'},
},
}
Object.values(state.labels).forEach(add)
it('should return an empty array for an empty query', () => {
const labels = filterLabelsByQuery(state, [], '')
expect(labels).toHaveLength(0)
})
it('should return labels for a query', () => {
const labels = filterLabelsByQuery(state, [], 'label2')
expect(labels).toHaveLength(1)
expect(labels[0].title).toBe('label2')
})
it('should not return found but hidden labels', () => {
interface label {
id: number,
title: string,
}
const labelsToHide: label[] = [{id: 1, title: 'label1'}]
const labels = filterLabelsByQuery(state, labelsToHide, 'label1')
expect(labels).toHaveLength(0)
})
})

@ -1,33 +0,0 @@
import {createNewIndexer} from '../indexes'
import type {LabelState} from '@/store/types'
import type {ILabel} from '@/modelTypes/ILabel'
const {search} = createNewIndexer('labels', ['title', 'description'])
/**
* Checks if a list of labels is available in the store and filters them then query
* @param {Object} state
* @param {Array} labelsToHide
* @param {String} query
* @returns {Array}
*/
export function filterLabelsByQuery(state: LabelState, labelsToHide: ILabel[], query: string) {
const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
return search(query)
?.filter(value => !labelIdsToHide.includes(value))
.map(id => state.labels[id])
|| []
}
/**
* Returns the labels by id if found
* @param {Object} state
* @param {Array} ids
* @returns {Array}
*/
export function getLabelsByIds(state: LabelState, ids: ILabel['id'][]) {
return Object.values(state.labels).filter(({id}) => ids.includes(id))
}

@ -2,6 +2,7 @@ import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import {error, success} from './message'
@ -104,6 +105,9 @@ if (window.SENTRY_ENABLED) {
import('./sentry').then(sentry => sentry.default(app, router))
}
const pinia = createPinia()
app.use(pinia)
app.use(store, key) // pass the injection key
app.use(router)
app.use(i18n)

@ -1,4 +1,5 @@
import type { ActionContext } from 'vuex'
import type { StoreDefinition } from 'pinia'
import {LOADING, LOADING_MODULE} from './mutation-types'
import type { RootStoreState } from './types'
@ -31,4 +32,22 @@ export function setLoading<State>(
loadFunc(false)
}
}
}
export const setLoadingPinia = (store: StoreDefinition, loadFunc : ((isLoading: boolean) => void) | null = null) => {
const timeout = setTimeout(() => {
if (loadFunc === null) {
store.isLoading = true
} else {
loadFunc(true)
}
}, 100)
return () => {
clearTimeout(timeout)
if (loadFunc === null) {
store.isLoading = false
} else {
loadFunc(false)
}
}
}

@ -20,7 +20,6 @@ import kanban from './modules/kanban'
import tasks from './modules/tasks'
import lists from './modules/lists'
import attachments from './modules/attachments'
import labels from './modules/labels'
import ListModel from '@/models/list'
@ -46,7 +45,6 @@ export const store = createStore<RootStoreState>({
tasks,
lists,
attachments,
labels,
},
state: () => ({
loading: false,

@ -1,121 +0,0 @@
import type { Module } from 'vuex'
import {i18n} from '@/i18n'
import {success} from '@/message'
import LabelService from '@/services/label'
import {setLoading} from '@/store/helper'
import type { LabelState, RootStoreState } from '@/store/types'
import {getLabelsByIds, filterLabelsByQuery} from '@/helpers/labels'
import {createNewIndexer} from '@/indexes'
import type { ILabel } from '@/modelTypes/ILabel'
const {add, remove, update} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1): Promise<ILabel[]> {
const labelService = new LabelService()
const labels = await labelService.getAll({}, {}, page) as ILabel[]
if (page < labelService.totalPages) {
const nextLabels = await getAllLabels(page + 1)
return labels.concat(nextLabels)
} else {
return labels
}
}
const LabelStore : Module<LabelState, RootStoreState> = {
namespaced: true,
state: () => ({
labels: {},
loaded: false,
}),
mutations: {
setLabels(state, labels: ILabel[]) {
labels.forEach(l => {
state.labels[l.id] = l
add(l)
})
},
setLabel(state, label: ILabel) {
state.labels[label.id] = label
update(label)
},
removeLabelById(state, label: ILabel) {
remove(label)
delete state.labels[label.id]
},
setLoaded(state, loaded: boolean) {
state.loaded = loaded
},
},
getters: {
getLabelsByIds(state) {
return (ids: ILabel['id'][]) => getLabelsByIds(state, ids)
},
filterLabelsByQuery(state) {
return (labelsToHide: ILabel[], query: string) => filterLabelsByQuery(state, labelsToHide, query)
},
getLabelsByExactTitles(state) {
return labelTitles => Object
.values(state.labels)
.filter(({title}) => labelTitles.some(l => l.toLowerCase() === title.toLowerCase()))
},
},
actions: {
async loadAllLabels(ctx, {forceLoad} = {}) {
if (ctx.state.loaded && !forceLoad) {
return
}
const cancel = setLoading(ctx, 'labels')
try {
const labels = await getAllLabels()
ctx.commit('setLabels', labels)
ctx.commit('setLoaded', true)
return labels
} finally {
cancel()
}
},
async deleteLabel(ctx, label: ILabel) {
const cancel = setLoading(ctx, 'labels')
const labelService = new LabelService()
try {
const result = await labelService.delete(label)
ctx.commit('removeLabelById', label)
success({message: i18n.global.t('label.deleteSuccess')})
return result
} finally {
cancel()
}
},
async updateLabel(ctx, label: ILabel) {
const cancel = setLoading(ctx, 'labels')
const labelService = new LabelService()
try {
const newLabel = await labelService.update(label)
ctx.commit('setLabel', newLabel)
success({message: i18n.global.t('label.edit.success')})
return newLabel
} finally {
cancel()
}
},
async createLabel(ctx, label: ILabel) {
const cancel = setLoading(ctx, 'labels')
const labelService = new LabelService()
try {
const newLabel = await labelService.create(label)
ctx.commit('setLabel', newLabel)
return newLabel
} finally {
cancel()
}
},
},
}
export default LabelStore

@ -25,6 +25,7 @@ import type { IAttachment } from '@/modelTypes/IAttachment'
import type { IList } from '@/modelTypes/IList'
import type { RootStoreState, TaskState } from '@/store/types'
import { useLabelStore } from '@/stores/labels'
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value) {
@ -268,22 +269,19 @@ const tasksStore : Module<TaskState, RootStoreState>= {
},
// Do everything that is involved in finding, creating and adding the label to the task
async addLabelsToTask({rootState, dispatch}, {
task,
parsedLabels,
}) {
async addLabelsToTask(_, { task, parsedLabels }) {
if (parsedLabels.length <= 0) {
return task
}
const {labels} = rootState.labels
const labelStore = useLabelStore()
const labelAddsToWaitFor = parsedLabels.map(async labelTitle => {
let label = validateLabel(labels, labelTitle)
let label = validateLabel(labelStore.labels, labelTitle)
if (typeof label === 'undefined') {
// label not found, create it
const labelModel = new LabelModel({title: labelTitle})
label = await dispatch('labels/createLabel', labelModel, {root: true})
label = await labelStore.createLabel(labelModel)
}
return addLabelToTask(task, label)

@ -93,7 +93,7 @@ export interface LabelState {
labels: {
[id: ILabel['id']]: ILabel
},
loaded: boolean,
isLoading: boolean,
}
export interface ListState {

55
src/stores/labels.test.ts Normal file

@ -0,0 +1,55 @@
import {setActivePinia, createPinia} from 'pinia'
import {describe, it, expect, beforeEach} from 'vitest'
import {useLabelStore} from './labels'
import type { ILabel } from '@/modelTypes/ILabel'
const MOCK_LABELS = {
1: {id: 1, title: 'label1'},
2: {id: 2, title: 'label2'},
3: {id: 3, title: 'label3'},
4: {id: 4, title: 'label4'},
5: {id: 5, title: 'label5'},
6: {id: 6, title: 'label6'},
7: {id: 7, title: 'label7'},
8: {id: 8, title: 'label8'},
9: {id: 9, title: 'label9'},
}
function setupStore() {
const store = useLabelStore()
store.setLabels(Object.values(MOCK_LABELS) as ILabel[])
return store
}
describe('filter labels', () => {
beforeEach(() => {
// creates a fresh pinia and make it active so it's automatically picked
// up by any useStore() call without having to pass it to it:
// `useStore(pinia)`
setActivePinia(createPinia())
})
it('should return an empty array for an empty query', () => {
const store = setupStore()
const labels = store.filterLabelsByQuery([], '')
expect(labels).toHaveLength(0)
})
it('should return labels for a query', () => {
const store = setupStore()
const labels = store.filterLabelsByQuery([], 'label2')
expect(labels).toHaveLength(1)
expect(labels[0].title).toBe('label2')
})
it('should not return found but hidden labels', () => {
const store = setupStore()
const labelsToHide = [{id: 1, title: 'label1'}] as ILabel[]
const labels = store.filterLabelsByQuery(labelsToHide, 'label1')
expect(labels).toHaveLength(0)
})
})

136
src/stores/labels.ts Normal file

@ -0,0 +1,136 @@
import { defineStore } from 'pinia'
import LabelService from '@/services/label'
import {success} from '@/message'
import {i18n} from '@/i18n'
import {createNewIndexer} from '@/indexes'
import {setLoadingPinia} from '@/store/helper'
import type {ILabel} from '@/modelTypes/ILabel'
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1): Promise<ILabel[]> {
const labelService = new LabelService()
const labels = await labelService.getAll({}, {}, page) as ILabel[]
if (page < labelService.totalPages) {
const nextLabels = await getAllLabels(page + 1)
return labels.concat(nextLabels)
} else {
return labels
}
}
import type {LabelState} from '@/store/types'
export const useLabelStore = defineStore('label', {
state: () : LabelState => ({
// The labels are stored as an object which has the label ids as keys.
labels: {},
isLoading: false,
}),
getters: {
getLabelsByIds(state) {
return (ids: ILabel['id'][]) => Object.values(state.labels).filter(({id}) => ids.includes(id))
},
// **
// * Checks if a list of labels is available in the store and filters them then query
// **
filterLabelsByQuery(state) {
return (labelsToHide: ILabel[], query: string) => {
const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
return search(query)
?.filter(value => !labelIdsToHide.includes(value))
.map(id => state.labels[id])
|| []
}
},
getLabelsByExactTitles(state) {
return (labelTitles: string[]) => Object
.values(state.labels)
.filter(({title}) => labelTitles.some(l => l.toLowerCase() === title.toLowerCase()))
},
},
actions: {
setIsLoading(isLoading: boolean) {
this.isLoading = isLoading
},
setLabels(labels: ILabel[]) {
labels.forEach(l => {
this.labels[l.id] = l
add(l)
})
},
setLabel(label: ILabel) {
this.labels[label.id] = label
update(label)
},
removeLabelById(label: ILabel) {
remove(label)
delete this.labels[label.id]
},
async loadAllLabels({forceLoad} : {forceLoad?: boolean} = {}) {
if (this.isLoading && !forceLoad) {
return
}
const cancel = setLoadingPinia(useLabelStore)
try {
const labels = await getAllLabels()
this.setLabels(labels)
this.setIsLoading(true)
return labels
} finally {
cancel()
}
},
async deleteLabel(label: ILabel) {
const cancel = setLoadingPinia(useLabelStore)
const labelService = new LabelService()
try {
const result = await labelService.delete(label)
this.removeLabelById(label)
success({message: i18n.global.t('label.deleteSuccess')})
return result
} finally {
cancel()
}
},
async updateLabel(label: ILabel) {
const cancel = setLoadingPinia(useLabelStore)
const labelService = new LabelService()
try {
const newLabel = await labelService.update(label)
this.setLabel(newLabel)
success({message: i18n.global.t('label.edit.success')})
return newLabel
} finally {
cancel()
}
},
async createLabel(label: ILabel) {
const cancel = setLoadingPinia(useLabelStore)
const labelService = new LabelService()
try {
const newLabel = await labelService.create(label)
this.setLabel(newLabel)
return newLabel
} finally {
cancel()
}
},
},
})

@ -111,11 +111,12 @@
<script lang="ts">
import {defineComponent} from 'vue'
import {mapState} from 'vuex'
import {mapState as mapVuexState} from 'vuex'
import {mapState} from 'pinia'
import LabelModel from '@/models/label'
import LabelModel from '../../models/label'
import type {ILabel} from '@/modelTypes/ILabel'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import {useLabelStore} from '@/stores/labels'
import BaseButton from '@/components/base/BaseButton.vue'
import AsyncEditor from '@/components/input/AsyncEditor'
@ -139,25 +140,32 @@ export default defineComponent({
}
},
created() {
this.$store.dispatch('labels/loadAllLabels')
const labelStore = useLabelStore()
labelStore.loadAllLabels()
},
mounted() {
setTitle(this.$t('label.title'))
},
computed: mapState({
userInfo: state => state.auth.info,
// Alphabetically sort the labels
labels: state => Object.values(state.labels.labels).sort((f, s) => f.title > s.title ? 1 : -1),
loading: state => state[LOADING] && state[LOADING_MODULE] === 'labels',
}),
computed: {
...mapVuexState({
userInfo: state => state.auth.info,
}),
...mapState(useLabelStore, {
// Alphabetically sort the labels
labels: state => Object.values(state.labels).sort((f, s) => f.title > s.title ? 1 : -1),
loading: state => state.isLoading,
}),
},
methods: {
deleteLabel(label: ILabel) {
this.showDeleteModal = false
this.isLabelEdit = false
return this.$store.dispatch('labels/deleteLabel', label)
const labelStore = useLabelStore()
return labelStore.deleteLabel(label)
},
editLabelSubmit() {
return this.$store.dispatch('labels/updateLabel', this.labelEditLabel)
const labelStore = useLabelStore()
return labelStore.updateLabel(this.labelEditLabel)
},
editLabel(label: ILabel) {
if (label.createdBy.id !== this.userInfo.id) {

@ -36,12 +36,13 @@
<script lang="ts">
import {defineComponent} from 'vue'
import {mapState} from 'pinia'
import LabelModel from '../../models/label'
import CreateEdit from '@/components/misc/create-edit.vue'
import ColorPicker from '../../components/input/colorPicker.vue'
import {mapState} from 'vuex'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import { setTitle } from '@/helpers/setTitle'
import { useLabelStore } from '@/stores/labels'
export default defineComponent({
name: 'NewLabel',
@ -58,9 +59,11 @@ export default defineComponent({
mounted() {
setTitle(this.$t('label.create.title'))
},
computed: mapState({
loading: state => state[LOADING] && state[LOADING_MODULE] === 'labels',
}),
computed: {
...mapState(useLabelStore, {
loading: state => state.isLoading,
}),
},
methods: {
async newLabel() {
if (this.label.title === '') {
@ -69,7 +72,8 @@ export default defineComponent({
}
this.showError = false
const label = this.$store.dispatch('labels/createLabel', this.label)
const labelStore = useLabelStore()
const label = labelStore.createLabel(this.label)
this.$router.push({
name: 'labels.index',
params: {id: label.id},

@ -10257,6 +10257,14 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pinia@^2.0.21:
version "2.0.21"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.21.tgz#2a6599ad3736fa71866f4b053ffb0073cd482270"
integrity sha512-6ol04PtL29O0Z6JHI47O3JUSoyOJ7Og0rstXrHVMZSP4zAldsQBXJCNF0i/H7m8vp/Hjd/CSmuPl7C5QAwpeWQ==
dependencies:
"@vue/devtools-api" "^6.2.1"
vue-demi "*"
pinkie-promise@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"