diff --git a/webapp/src/archiver.ts b/webapp/src/archiver.ts index 904d38aaf..5f95c3462 100644 --- a/webapp/src/archiver.ts +++ b/webapp/src/archiver.ts @@ -8,7 +8,7 @@ import {Utils} from './utils' interface Archive { version: number date: number - blocks: IBlock[] + blocks: readonly IBlock[] } class Archiver { diff --git a/webapp/src/boardTree.ts b/webapp/src/boardTree.ts index 7c92cad00..05e4df99f 100644 --- a/webapp/src/boardTree.ts +++ b/webapp/src/boardTree.ts @@ -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 { - 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 { - 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 } diff --git a/webapp/src/cardTree.ts b/webapp/src/cardTree.ts index 53fe6bd0e..34b1b7c97 100644 --- a/webapp/src/cardTree.ts +++ b/webapp/src/cardTree.ts @@ -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 { - 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 } diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index bb9375cca..f3a6b47db 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -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 { 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(() => { diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index b1b414cd9..95bd428de 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -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 diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index ef4972393..26ee20413 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -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 { 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 { } 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 { await workspaceTree.sync() if (boardId) { - const boardTree = new BoardTree(boardId) + const boardTree = new MutableBoardTree(boardId) await boardTree.sync() // Default to first view diff --git a/webapp/src/workspaceTree.ts b/webapp/src/workspaceTree.ts index ae791bf32..f5fb4cefe 100644 --- a/webapp/src/workspaceTree.ts +++ b/webapp/src/workspaceTree.ts @@ -5,17 +5,23 @@ import {Board} from './blocks/board' import octoClient from './octoClient' import {OctoUtils} from './octoUtils' -class WorkspaceTree { - boards: Board[] = [] - - async sync(): Promise { - 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 + +export { MutableWorkspaceTree, WorkspaceTree }