1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-10-31 00:17:42 +02:00
Files
focalboard/webapp/src/store/cards.ts
2022-05-02 14:48:45 -06:00

326 lines
12 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSlice, PayloadAction, createSelector} from '@reduxjs/toolkit'
import {Card} from '../blocks/card'
import {IUser} from '../user'
import {Board} from '../blocks/board'
import {BoardView} from '../blocks/boardView'
import {CommentBlock} from '../blocks/commentBlock'
import {Utils} from '../utils'
import {Constants} from '../constants'
import {CardFilter} from '../cardFilter'
import {loadBoardData, initialReadOnlyLoad} from './initialLoad'
import {getCurrentBoard} from './boards'
import {getBoardUsers} from './users'
import {getLastCommentByCard} from './comments'
import {getCurrentView} from './views'
import {getSearchText} from './searchText'
import {RootState} from './index'
type CardsState = {
current: string
cards: {[key: string]: Card}
templates: {[key: string]: Card}
}
const cardsSlice = createSlice({
name: 'cards',
initialState: {
current: '',
cards: {},
templates: {},
} as CardsState,
reducers: {
setCurrent: (state, action: PayloadAction<string>) => {
state.current = action.payload
},
addCard: (state, action: PayloadAction<Card>) => {
state.cards[action.payload.id] = action.payload
},
addTemplate: (state, action: PayloadAction<Card>) => {
state.templates[action.payload.id] = action.payload
},
updateCards: (state, action: PayloadAction<Card[]>) => {
for (const card of action.payload) {
if (card.deleteAt !== 0) {
delete state.cards[card.id]
delete state.templates[card.id]
} else if (card.fields.isTemplate) {
state.templates[card.id] = card
} else {
state.cards[card.id] = card
}
}
},
},
extraReducers: (builder) => {
builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => {
state.cards = {}
state.templates = {}
for (const block of action.payload.blocks) {
if (block.type === 'card' && block.fields.isTemplate) {
state.templates[block.id] = block as Card
} else if (block.type === 'card' && !block.fields.isTemplate) {
state.cards[block.id] = block as Card
}
}
})
builder.addCase(loadBoardData.fulfilled, (state, action) => {
state.cards = {}
state.templates = {}
for (const block of action.payload.blocks) {
if (block.type === 'card' && block.fields.isTemplate) {
state.templates[block.id] = block as Card
} else if (block.type === 'card' && !block.fields.isTemplate) {
state.cards[block.id] = block as Card
}
}
})
},
})
export const {updateCards, addCard, addTemplate, setCurrent} = cardsSlice.actions
export const {reducer} = cardsSlice
export const getCards = (state: RootState): {[key: string]: Card} => state.cards.cards
export const getSortedCards = createSelector(
getCards,
(cards) => {
return Object.values(cards).sort((a, b) => a.title.localeCompare(b.title)) as Card[]
},
)
export const getTemplates = (state: RootState): {[key: string]: Card} => state.cards.templates
export const getSortedTemplates = createSelector(
getTemplates,
(templates) => {
return Object.values(templates).sort((a, b) => a.title.localeCompare(b.title)) as Card[]
},
)
export function getCard(cardId: string): (state: RootState) => Card|undefined {
return (state: RootState): Card|undefined => {
return state.cards.cards[cardId] || state.cards.templates[cardId]
}
}
export const getCurrentBoardCards = createSelector(
(state: RootState) => state.boards.current,
getCards,
(boardId, cards) => {
return Object.values(cards).filter((c) => c.boardId === boardId) as Card[]
},
)
export const getCurrentBoardTemplates = createSelector(
(state: RootState) => state.boards.current,
getTemplates,
(boardId, templates) => {
return Object.values(templates).filter((c) => c.boardId === boardId) as Card[]
},
)
function titleOrCreatedOrder(cardA: Card, cardB: Card) {
const aValue = cardA.title
const bValue = cardB.title
if (aValue && bValue) {
return aValue.localeCompare(bValue)
}
// Always put untitled cards at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
// If both cards are untitled, use the create date
return cardA.createAt - cardB.createAt
}
function manualOrder(activeView: BoardView, cardA: Card, cardB: Card) {
const indexA = activeView.fields.cardOrder.indexOf(cardA.id)
const indexB = activeView.fields.cardOrder.indexOf(cardB.id)
if (indexA < 0 && indexB < 0) {
return titleOrCreatedOrder(cardA, cardB)
} else if (indexA < 0 && indexB >= 0) {
// If cardA's order is not defined, put it at the end
return 1
}
return indexA - indexB
}
function sortCards(cards: Card[], lastCommentByCard: {[key: string]: CommentBlock}, board: Board, activeView: BoardView, usersById: {[key: string]: IUser}): Card[] {
if (!activeView) {
return cards
}
const {sortOptions} = activeView.fields
if (sortOptions.length < 1) {
Utils.log('Manual sort')
return cards.sort((a, b) => manualOrder(activeView, a, b))
}
let sortedCards = cards
for (const sortOption of sortOptions) {
if (sortOption.propertyId === Constants.titleColumnId) {
Utils.log('Sort by title')
sortedCards = sortedCards.sort((a, b) => {
const result = titleOrCreatedOrder(a, b)
return sortOption.reversed ? -result : result
})
} else {
const sortPropertyId = sortOption.propertyId
const template = board.cardProperties.find((o) => o.id === sortPropertyId)
if (!template) {
Utils.logError(`Missing template for property id: ${sortPropertyId}`)
return sortedCards
}
Utils.log(`Sort by property: ${template?.name}`)
sortedCards = sortedCards.sort((a, b) => {
// Always put cards with no titles at the bottom, regardless of sort
let aValue = a.fields.properties[sortPropertyId] || ''
let bValue = b.fields.properties[sortPropertyId] || ''
if (template.type === 'createdBy') {
aValue = usersById[a.createdBy]?.username || ''
bValue = usersById[b.createdBy]?.username || ''
} else if (template.type === 'updatedBy') {
aValue = usersById[a.modifiedBy]?.username || ''
bValue = usersById[b.modifiedBy]?.username || ''
} else if (template.type === 'date') {
aValue = (aValue === '') ? '' : JSON.parse(aValue as string).from
bValue = (bValue === '') ? '' : JSON.parse(bValue as string).from
}
let result = 0
if (template.type === 'number' || template.type === 'date') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return titleOrCreatedOrder(a, b)
}
result = Number(aValue) - Number(bValue)
} else if (template.type === 'createdTime') {
result = a.createAt - b.createAt
} else if (template.type === 'updatedTime') {
const aUpdateAt = Math.max(a.updateAt, lastCommentByCard[a.id]?.updateAt || 0)
const bUpdateAt = Math.max(b.updateAt, lastCommentByCard[b.id]?.updateAt || 0)
result = aUpdateAt - bUpdateAt
} else {
// Text-based sort
if (aValue.length > 0 && bValue.length <= 0) {
return -1
}
if (bValue.length > 0 && aValue.length <= 0) {
return 1
}
if (aValue.length <= 0 && bValue.length <= 0) {
return titleOrCreatedOrder(a, b)
}
if (template.type === 'select' || template.type === 'multiSelect') {
aValue = template.options.find((o) => o.id === (Array.isArray(aValue) ? aValue[0] : aValue))?.value || ''
bValue = template.options.find((o) => o.id === (Array.isArray(bValue) ? bValue[0] : bValue))?.value || ''
}
result = (aValue as string).localeCompare(bValue as string)
}
if (result === 0) {
// In case of "ties", use the title order
result = titleOrCreatedOrder(a, b)
}
return sortOption.reversed ? -result : result
})
}
}
return sortedCards
}
function searchFilterCards(cards: Card[], board: Board, searchTextRaw: string): Card[] {
const searchText = searchTextRaw.toLocaleLowerCase()
if (!searchText) {
return cards.slice()
}
return cards.filter((card: Card) => {
const searchTextInCardTitle: boolean = card.title?.toLocaleLowerCase().includes(searchText)
if (searchTextInCardTitle) {
return true
}
for (const [propertyId, propertyValue] of Object.entries(card.fields.properties)) {
// TODO: Refactor to a shared function that returns the display value of a property
const propertyTemplate = board.cardProperties.find((o) => o.id === propertyId)
if (propertyTemplate && propertyValue) {
if (propertyTemplate.type === 'select') {
// Look up the value of the select option
const option = propertyTemplate.options.find((o) => o.id === propertyValue)
if (option?.value.toLowerCase().includes(searchText)) {
return true
}
} else if (propertyTemplate.type === 'multiSelect') {
// Look up the value of the select option
const options = (propertyValue as string[]).map((value) => propertyTemplate.options.find((o) => o.id === value)?.value.toLowerCase())
if (options?.includes(searchText)) {
return true
}
} else if ((propertyValue as string).toLowerCase().includes(searchText)) {
return true
}
}
}
return false
})
}
export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
getCurrentBoardCards,
getLastCommentByCard,
getCurrentBoard,
getCurrentView,
getSearchText,
getBoardUsers,
(cards, lastCommentByCard, board, view, searchText, users) => {
if (!view || !board || !users || !cards) {
return []
}
let result = cards
if (view.fields.filter) {
result = CardFilter.applyFilterGroup(view.fields.filter, board.cardProperties, result)
}
if (searchText) {
result = searchFilterCards(result, board, searchText)
}
result = sortCards(result, lastCommentByCard, board, view, users)
return result
},
)
export const getCurrentCard = createSelector(
(state: RootState) => state.cards.current,
(state: RootState) => state.cards.cards,
(current, cards) => cards[current],
)