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

Merged
konrad merged 2 commits from dpschen/frontend:feature/feat-pinia-label-store into main 2022-09-21 14:24:00 +00:00
16 changed files with 206 additions and 165 deletions
Showing only changes of commit 3502a3f4c1 - Show all commits

View File

@ -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",

View File

@ -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>

View File

@ -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()
dpschen marked this conversation as resolved
Review

Shouldn't this use the setup function? (not script setup).

Shouldn't this use the setup function? (not `script setup`).
Review

We could introduce a setup block in this component.
But since this pull request was only about having the fastest way to integrate the store I didn't see that necessary.

Setup would only require the additional import of computed. Plus we would need to export it again from that function.
So overall more complex then this quick fix here.

We could introduce a setup block in this component. But since this pull request was only about having the fastest way to integrate the store I didn't see that necessary. Setup would only require the additional import of `computed`. Plus we would need to export it again from that function. So overall more complex then this quick fix here.
Review

Makes sense, especially considering this would be redundant once we'll have everything migrated over to script setup.

Makes sense, especially considering this would be redundant once we'll have everything migrated over to `script setup`.
Review

Exactly :)

Exactly :)
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.

View File

@ -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')})

View File

@ -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)
})
})

View File

@ -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))
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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
View 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)
})
})

View File

@ -1,15 +1,13 @@
import type { Module } from 'vuex'
import { defineStore } from 'pinia'
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 {success} from '@/message'
import {i18n} from '@/i18n'
import {createNewIndexer} from '@/indexes'
import type { ILabel } from '@/modelTypes/ILabel'
import {setLoadingPinia} from '@/store/helper'
import type {ILabel} from '@/modelTypes/ILabel'
const {add, remove, update} = createNewIndexer('labels', ['title', 'description'])
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1): Promise<ILabel[]> {
const labelService = new LabelService()
@ -22,100 +20,117 @@ async function getAllLabels(page = 1): Promise<ILabel[]> {
}
}
const LabelStore : Module<LabelState, RootStoreState> = {
namespaced: true,
state: () => ({
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: {},
loaded: false,
isLoading: 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)
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) => filterLabelsByQuery(state, labelsToHide, query)
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 => Object
return (labelTitles: string[]) => Object
.values(state.labels)
.filter(({title}) => labelTitles.some(l => l.toLowerCase() === title.toLowerCase()))
},
},
actions: {
async loadAllLabels(ctx, {forceLoad} = {}) {
if (ctx.state.loaded && !forceLoad) {
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 = setLoading(ctx, 'labels')
const cancel = setLoadingPinia(useLabelStore)
try {
const labels = await getAllLabels()
ctx.commit('setLabels', labels)
ctx.commit('setLoaded', true)
this.setLabels(labels)
this.setIsLoading(true)
return labels
} finally {
cancel()
}
},
async deleteLabel(ctx, label: ILabel) {
const cancel = setLoading(ctx, 'labels')
async deleteLabel(label: ILabel) {
const cancel = setLoadingPinia(useLabelStore)
const labelService = new LabelService()
try {
const result = await labelService.delete(label)
ctx.commit('removeLabelById', label)
this.removeLabelById(label)
success({message: i18n.global.t('label.deleteSuccess')})
return result
} finally {
cancel()
}
},
async updateLabel(ctx, label: ILabel) {
const cancel = setLoading(ctx, 'labels')
async updateLabel(label: ILabel) {
const cancel = setLoadingPinia(useLabelStore)
const labelService = new LabelService()
try {
const newLabel = await labelService.update(label)
ctx.commit('setLabel', newLabel)
this.setLabel(newLabel)
success({message: i18n.global.t('label.edit.success')})
return newLabel
} finally {
cancel()
}
},
async createLabel(ctx, label: ILabel) {
const cancel = setLoading(ctx, 'labels')
async createLabel(label: ILabel) {
const cancel = setLoadingPinia(useLabelStore)
const labelService = new LabelService()
try {
const newLabel = await labelService.create(label)
ctx.commit('setLabel', newLabel)
this.setLabel(newLabel)
return newLabel
} finally {
cancel()
}
},
},
}
export default LabelStore
})

View File

@ -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) {

View File

@ -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},

View File

@ -10240,6 +10240,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"