Immutable view state objects

This commit is contained in:
Chen-I Lim 2020-10-20 14:32:39 -07:00
parent 29fa94a937
commit 932de3a17f
7 changed files with 332 additions and 359 deletions

View file

@ -8,7 +8,7 @@ import {Utils} from './utils'
interface Archive {
version: number
date: number
blocks: IBlock[]
blocks: readonly IBlock[]
}
class Archiver {

View file

@ -12,313 +12,279 @@ import {Utils} from './utils'
type Group = { option: IPropertyOption, cards: Card[] }
class BoardTree {
board!: Board
views: BoardView[] = []
cards: Card[] = []
emptyGroupCards: Card[] = []
groups: Group[] = []
interface BoardTree {
readonly board: Board
readonly views: readonly BoardView[]
readonly cards: readonly Card[]
readonly emptyGroupCards: readonly Card[]
readonly groups: readonly Group[]
readonly allBlocks: readonly IBlock[]
activeView?: BoardView
groupByProperty?: IPropertyTemplate
readonly activeView?: BoardView
readonly groupByProperty?: IPropertyTemplate
private searchText?: string
private allCards: Card[] = []
get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards]
}
constructor(private boardId: string) {
}
async sync(): Promise<void> {
const blocks = await octoClient.getSubtree(this.boardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: Block[]): void {
this.board = blocks.find((block) => block.type === 'board') as Board
this.views = blocks.filter((block) => block.type === 'view') as BoardView[]
this.allCards = blocks.filter((block) => block.type === 'card') as Card[]
this.cards = []
this.ensureMinimumSchema()
}
private async ensureMinimumSchema(): Promise<boolean> {
const {board} = this
let didChange = false
// At least one select property
const selectProperties = board.cardProperties.find((o) => o.type === 'select')
if (!selectProperties) {
const property: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Status',
type: 'select',
options: [],
}
board.cardProperties.push(property)
didChange = true
}
// At least one view
if (this.views.length < 1) {
const view = new BoardView()
view.parentId = board.id
view.groupById = board.cardProperties.find((o) => o.type === 'select')?.id
this.views.push(view)
didChange = true
}
return didChange
}
setActiveView(viewId: string): void {
this.activeView = this.views.find((o) => o.id === viewId)
if (!this.activeView) {
Utils.logError(`Cannot find BoardView: ${viewId}`)
this.activeView = this.views[0]
}
// Fix missing group by (e.g. for new views)
if (this.activeView.viewType === 'board' && !this.activeView.groupById) {
this.activeView.groupById = this.board.cardProperties.find((o) => o.type === 'select')?.id
}
this.applyFilterSortAndGroup()
}
getSearchText(): string | undefined {
return this.searchText
}
setSearchText(text?: string): void {
this.searchText = text
this.applyFilterSortAndGroup()
}
applyFilterSortAndGroup(): void {
Utils.assert(this.allCards !== undefined)
this.cards = this.filterCards(this.allCards)
Utils.assert(this.cards !== undefined)
this.cards = this.searchFilterCards(this.cards)
Utils.assert(this.cards !== undefined)
this.cards = this.sortCards(this.cards)
Utils.assert(this.cards !== undefined)
if (this.activeView.groupById) {
this.setGroupByProperty(this.activeView.groupById)
} else {
Utils.assert(this.activeView.viewType !== 'board')
}
Utils.assert(this.cards !== undefined)
}
private searchFilterCards(cards: Card[]): Card[] {
const searchText = this.searchText?.toLocaleLowerCase()
if (!searchText) {
return cards.slice()
}
return cards.filter((card) => {
return (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1)
})
}
private setGroupByProperty(propertyId: string) {
const {board} = this
let property = board.cardProperties.find((o) => o.id === propertyId)
// TODO: Handle multi-select
if (!property || property.type !== 'select') {
Utils.logError(`this.view.groupById card property not found: ${propertyId}`)
property = board.cardProperties.find((o) => o.type === 'select')
Utils.assertValue(property)
}
this.groupByProperty = property
this.groupCards()
}
private groupCards() {
this.groups = []
const groupByPropertyId = this.groupByProperty.id
this.emptyGroupCards = this.cards.filter((o) => {
const propertyValue = o.properties[groupByPropertyId]
return !propertyValue || !this.groupByProperty.options.find((option) => option.value === propertyValue)
})
const propertyOptions = this.groupByProperty.options || []
for (const option of propertyOptions) {
const cards = this.cards.
filter((o) => {
const propertyValue = o.properties[groupByPropertyId]
return propertyValue && propertyValue === option.value
})
const group: Group = {
option,
cards,
}
this.groups.push(group)
}
}
private filterCards(cards: Card[]): Card[] {
const {board} = this
const filterGroup = this.activeView?.filter
if (!filterGroup) {
return cards.slice()
}
return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards)
}
private sortCards(cards: Card[]): Card[] {
if (!this.activeView) {
Utils.assertFailure()
return cards
}
const {board} = this
const {sortOptions} = this.activeView
let sortedCards: Card[] = []
if (sortOptions.length < 1) {
Utils.log('Default sort')
sortedCards = cards.sort((a, b) => {
const aValue = a.title || ''
const bValue = b.title || ''
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
return a.createAt - b.createAt
})
} else {
for (const sortOption of sortOptions) {
if (sortOption.propertyId === '__name') {
Utils.log('Sort by name')
sortedCards = cards.sort((a, b) => {
const aValue = a.title || ''
const bValue = b.title || ''
// Always put empty values at the bottom, newest last
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
let result = aValue.localeCompare(bValue)
if (sortOption.reversed) {
result = -result
}
return 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 cards.slice()
}
Utils.log(`Sort by ${template?.name}`)
sortedCards = cards.sort((a, b) => {
// Always put cards with no titles at the bottom
if (a.title && !b.title) {
return -1
}
if (b.title && !a.title) {
return 1
}
if (!a.title && !b.title) {
return a.createAt - b.createAt
}
const aValue = a.properties[sortPropertyId] || ''
const bValue = b.properties[sortPropertyId] || ''
let result = 0
if (template.type === 'select') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
// Sort by the option order (not alphabetically by value)
const aOrder = template.options.findIndex((o) => o.value === aValue)
const bOrder = template.options.findIndex((o) => o.value === bValue)
result = aOrder - bOrder
} else 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 a.createAt - b.createAt
}
result = Number(aValue) - Number(bValue)
} else if (template.type === 'createdTime') {
result = a.createAt - b.createAt
} else if (template.type === 'updatedTime') {
result = a.updateAt - b.updateAt
} else {
// Text-based sort
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
result = aValue.localeCompare(bValue)
}
if (sortOption.reversed) {
result = -result
}
return result
})
}
}
}
return sortedCards
}
getSearchText(): string | undefined
}
export {BoardTree}
class MutableBoardTree implements BoardTree {
board!: Board
views: BoardView[] = []
cards: Card[] = []
emptyGroupCards: Card[] = []
groups: Group[] = []
activeView?: BoardView
groupByProperty?: IPropertyTemplate
private searchText?: string
private allCards: Card[] = []
get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards]
}
constructor(private boardId: string) {
}
async sync() {
const blocks = await octoClient.getSubtree(this.boardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: Block[]) {
this.board = blocks.find(block => block.type === "board") as Board
this.views = blocks.filter(block => block.type === "view") as BoardView[]
this.allCards = blocks.filter(block => block.type === "card") as Card[]
this.cards = []
this.ensureMinimumSchema()
}
private async ensureMinimumSchema() {
const { board } = this
let didChange = false
// At least one select property
const selectProperties = board.cardProperties.find(o => o.type === "select")
if (!selectProperties) {
const property: IPropertyTemplate = {
id: Utils.createGuid(),
name: "Status",
type: "select",
options: []
}
board.cardProperties.push(property)
didChange = true
}
// At least one view
if (this.views.length < 1) {
const view = new BoardView()
view.parentId = board.id
view.groupById = board.cardProperties.find(o => o.type === "select")?.id
this.views.push(view)
didChange = true
}
return didChange
}
setActiveView(viewId: string) {
this.activeView = this.views.find(o => o.id === viewId)
if (!this.activeView) {
Utils.logError(`Cannot find BoardView: ${viewId}`)
this.activeView = this.views[0]
}
// Fix missing group by (e.g. for new views)
if (this.activeView.viewType === "board" && !this.activeView.groupById) {
this.activeView.groupById = this.board.cardProperties.find(o => o.type === "select")?.id
}
this.applyFilterSortAndGroup()
}
getSearchText(): string | undefined {
return this.searchText
}
setSearchText(text?: string) {
this.searchText = text
this.applyFilterSortAndGroup()
}
applyFilterSortAndGroup() {
Utils.assert(this.allCards !== undefined)
this.cards = this.filterCards(this.allCards)
Utils.assert(this.cards !== undefined)
this.cards = this.searchFilterCards(this.cards)
Utils.assert(this.cards !== undefined)
this.cards = this.sortCards(this.cards)
Utils.assert(this.cards !== undefined)
if (this.activeView.groupById) {
this.setGroupByProperty(this.activeView.groupById)
} else {
Utils.assert(this.activeView.viewType !== "board")
}
Utils.assert(this.cards !== undefined)
}
private searchFilterCards(cards: Card[]): Card[] {
const searchText = this.searchText?.toLocaleLowerCase()
if (!searchText) { return cards.slice() }
return cards.filter(card => {
if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) { return true }
})
}
private setGroupByProperty(propertyId: string) {
const { board } = this
let property = board.cardProperties.find(o => o.id === propertyId)
// TODO: Handle multi-select
if (!property || property.type !== "select") {
Utils.logError(`this.view.groupById card property not found: ${propertyId}`)
property = board.cardProperties.find(o => o.type === "select")
Utils.assertValue(property)
}
this.groupByProperty = property
this.groupCards()
}
private groupCards() {
this.groups = []
const groupByPropertyId = this.groupByProperty.id
this.emptyGroupCards = this.cards.filter(o => {
const propertyValue = o.properties[groupByPropertyId]
return !propertyValue || !this.groupByProperty.options.find(option => option.value === propertyValue)
})
const propertyOptions = this.groupByProperty.options || []
for (const option of propertyOptions) {
const cards = this.cards
.filter(o => {
const propertyValue = o.properties[groupByPropertyId]
return propertyValue && propertyValue === option.value
})
const group: Group = {
option,
cards
}
this.groups.push(group)
}
}
private filterCards(cards: Card[]): Card[] {
const { board } = this
const filterGroup = this.activeView?.filter
if (!filterGroup) { return cards.slice() }
return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards)
}
private sortCards(cards: Card[]): Card[] {
if (!this.activeView) { Utils.assertFailure(); return cards }
const { board } = this
const { sortOptions } = this.activeView
let sortedCards: Card[] = []
if (sortOptions.length < 1) {
Utils.log(`Default sort`)
sortedCards = cards.sort((a, b) => {
const aValue = a.title || ""
const bValue = b.title || ""
// Always put empty values at the bottom
if (aValue && !bValue) { return -1 }
if (bValue && !aValue) { return 1 }
if (!aValue && !bValue) { return a.createAt - b.createAt }
return a.createAt - b.createAt
})
} else {
sortOptions.forEach(sortOption => {
if (sortOption.propertyId === "__name") {
Utils.log(`Sort by name`)
sortedCards = cards.sort((a, b) => {
const aValue = a.title || ""
const bValue = b.title || ""
// Always put empty values at the bottom, newest last
if (aValue && !bValue) { return -1 }
if (bValue && !aValue) { return 1 }
if (!aValue && !bValue) { return a.createAt - b.createAt }
let result = aValue.localeCompare(bValue)
if (sortOption.reversed) { result = -result }
return 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 cards.slice()
}
Utils.log(`Sort by ${template?.name}`)
sortedCards = cards.sort((a, b) => {
// Always put cards with no titles at the bottom
if (a.title && !b.title) { return -1 }
if (b.title && !a.title) { return 1 }
if (!a.title && !b.title) { return a.createAt - b.createAt }
const aValue = a.properties[sortPropertyId] || ""
const bValue = b.properties[sortPropertyId] || ""
let result = 0
if (template.type === "select") {
// Always put empty values at the bottom
if (aValue && !bValue) { return -1 }
if (bValue && !aValue) { return 1 }
if (!aValue && !bValue) { return a.createAt - b.createAt }
// Sort by the option order (not alphabetically by value)
const aOrder = template.options.findIndex(o => o.value === aValue)
const bOrder = template.options.findIndex(o => o.value === bValue)
result = aOrder - bOrder
} else 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 a.createAt - b.createAt }
result = Number(aValue) - Number(bValue)
} else if (template.type === "createdTime") {
result = a.createAt - b.createAt
} else if (template.type === "updatedTime") {
result = a.updateAt - b.updateAt
} else {
// Text-based sort
// Always put empty values at the bottom
if (aValue && !bValue) { return -1 }
if (bValue && !aValue) { return 1 }
if (!aValue && !bValue) { return a.createAt - b.createAt }
result = aValue.localeCompare(bValue)
}
if (sortOption.reversed) { result = -result }
return result
})
}
})
}
return sortedCards
}
}
export { MutableBoardTree, BoardTree }

View file

@ -6,32 +6,35 @@ import octoClient from './octoClient'
import {IBlock, IOrderedBlock} from './octoTypes'
import {OctoUtils} from './octoUtils'
class CardTree {
card: Card
comments: IBlock[]
contents: IOrderedBlock[]
isSynched: boolean
constructor(private cardId: string) {
}
async sync(): Promise<void> {
const blocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: Block[]): void {
this.card = blocks.find((o) => o.id === this.cardId) as Card
this.comments = blocks.
filter((block) => block.type === 'comment').
sort((a, b) => a.createAt - b.createAt)
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image') as IOrderedBlock[]
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
this.isSynched = true
}
interface CardTree {
readonly card: Card
readonly comments: readonly IBlock[]
readonly contents: readonly IOrderedBlock[]
}
export {CardTree}
class MutableCardTree implements CardTree {
card: Card
comments: IBlock[]
contents: IOrderedBlock[]
constructor(private cardId: string) {
}
async sync() {
const blocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: Block[]) {
this.card = blocks.find(o => o.id === this.cardId) as Card
this.comments = blocks
.filter(block => block.type === "comment")
.sort((a, b) => a.createAt - b.createAt)
const contentBlocks = blocks.filter(block => block.type === "text" || block.type === "image") as IOrderedBlock[]
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
}
}
export { MutableCardTree, CardTree }

View file

@ -7,7 +7,7 @@ import {Block} from '../blocks/block'
import {Card} from '../blocks/card'
import {TextBlock} from '../blocks/textBlock'
import {BoardTree} from '../boardTree'
import {CardTree} from '../cardTree'
import {CardTree, MutableCardTree} from '../cardTree'
import {Menu as OldMenu, MenuOption} from '../menu'
import mutator from '../mutator'
import {OctoListener} from '../octoListener'
@ -46,7 +46,7 @@ export default class CardDetail extends React.Component<Props, State> {
await cardTree.sync()
this.setState({cardTree})
})
const cardTree = new CardTree(this.props.card.id)
const cardTree = new MutableCardTree(this.props.card.id)
cardTree.sync().then(() => {
this.setState({cardTree})
setTimeout(() => {

View file

@ -121,7 +121,7 @@ class OctoUtils {
return element
}
static getOrderBefore(block: IOrderedBlock, blocks: IOrderedBlock[]): number {
static getOrderBefore(block: IOrderedBlock, blocks: readonly IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === 0) {
return block.order / 2
@ -130,7 +130,7 @@ class OctoUtils {
return (block.order + previousBlock.order) / 2
}
static getOrderAfter(block: IOrderedBlock, blocks: IOrderedBlock[]): number {
static getOrderAfter(block: IOrderedBlock, blocks: readonly IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === blocks.length - 1) {
return block.order + 1000

View file

@ -4,7 +4,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import {BoardView} from '../blocks/boardView'
import {BoardTree} from '../boardTree'
import {BoardTree, MutableBoardTree} from '../boardTree'
import {CardTree} from '../cardTree'
import {FilterComponent} from '../components/filterComponent'
import {WorkspaceComponent} from '../components/workspaceComponent'
@ -12,7 +12,7 @@ import {FlashMessage} from '../flashMessage'
import mutator from '../mutator'
import {OctoListener} from '../octoListener'
import {Utils} from '../utils'
import {WorkspaceTree} from '../workspaceTree'
import {MutableWorkspaceTree, WorkspaceTree} from '../workspaceTree'
type Props = {
}
@ -20,9 +20,8 @@ type Props = {
type State = {
boardId: string
viewId: string
workspaceTree: WorkspaceTree
boardTree?: BoardTree
shownCardTree?: CardTree
workspaceTree: MutableWorkspaceTree
boardTree?: MutableBoardTree
filterAnchorElement?: HTMLElement
}
@ -44,7 +43,7 @@ export default class BoardPage extends React.Component<Props, State> {
this.state = {
boardId,
viewId,
workspaceTree: new WorkspaceTree(),
workspaceTree: new MutableWorkspaceTree(),
}
Utils.log(`BoardPage. boardId: ${boardId}`)
@ -106,8 +105,7 @@ export default class BoardPage extends React.Component<Props, State> {
}
render() {
const {workspaceTree, shownCardTree} = this.state
const {board, activeView} = this.state.boardTree || {}
const {workspaceTree} = this.state
if (this.state.filterAnchorElement) {
const element = this.state.filterAnchorElement
@ -178,7 +176,7 @@ export default class BoardPage extends React.Component<Props, State> {
await workspaceTree.sync()
if (boardId) {
const boardTree = new BoardTree(boardId)
const boardTree = new MutableBoardTree(boardId)
await boardTree.sync()
// Default to first view

View file

@ -5,17 +5,23 @@ import {Board} from './blocks/board'
import octoClient from './octoClient'
import {OctoUtils} from './octoUtils'
class WorkspaceTree {
boards: Board[] = []
async sync(): Promise<void> {
const blocks = await octoClient.getBlocks(undefined, 'board')
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: Block[]): void {
this.boards = blocks.filter((block) => block.type === 'board') as Board[]
}
interface WorkspaceTree {
readonly boards: readonly Board[]
}
export {WorkspaceTree}
class MutableWorkspaceTree {
boards: Board[] = []
async sync() {
const blocks = await octoClient.getBlocks(undefined, "board")
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: Block[]) {
this.boards = blocks.filter(block => block.type === "board") as Board[]
}
}
// type WorkspaceTree = Readonly<MutableWorkspaceTree>
export { MutableWorkspaceTree, WorkspaceTree }