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
17 changed files with 276 additions and 235 deletions

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

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

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

136
src/stores/labels.ts Normal file
View 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()
}
},
},
})

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"