Skip to content

Commit

Permalink
Refactor element-specific behavior out of ListModel
Browse files Browse the repository at this point in the history
We do not want ListModel to specifically only work on ListElement types,
or even element types in general. As such, we generalize it to a list of
some kind of elements with some kind of ID, even if these are not types
that would be stored remotely as part of the model.

Also renames entity/element to item for the ListModel.

Closes #8222

Co-authored-by: hrb-hub <[email protected]>
  • Loading branch information
paw-hub and hrb-hub committed Jan 10, 2025
1 parent ede0668 commit ce112b7
Show file tree
Hide file tree
Showing 18 changed files with 668 additions and 424 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import m, { Children, Component, Vnode } from "mithril"
import { assertMainOrNode } from "../../../../common/api/common/Env"
import { downcast, TypeRef } from "@tutao/tutanota-utils"
import { ListModel } from "../../../../common/misc/ListModel.js"
import { downcast } from "@tutao/tutanota-utils"
import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../../../common/gui/base/List.js"
import { size } from "../../../../common/gui/size.js"
import { CalendarEvent } from "../../../../common/api/entities/tutanota/TypeRefs.js"
Expand All @@ -12,6 +11,7 @@ import { theme } from "../../../../common/gui/theme.js"
import { VirtualRow } from "../../../../common/gui/base/ListUtils.js"
import { styles } from "../../../../common/gui/styles.js"
import { KindaCalendarRow } from "../../gui/CalendarRow.js"
import { ListElementListModel } from "../../../../common/misc/ListElementListModel"

assertMainOrNode()

Expand All @@ -24,14 +24,14 @@ export class CalendarSearchResultListEntry {
}

export interface CalendarSearchListViewAttrs {
listModel: ListModel<CalendarSearchResultListEntry>
listModel: ListElementListModel<CalendarSearchResultListEntry>
onSingleSelection: (item: CalendarSearchResultListEntry) => unknown
isFreeAccount: boolean
cancelCallback: () => unknown | null
}

export class CalendarSearchListView implements Component<CalendarSearchListViewAttrs> {
private listModel: ListModel<CalendarSearchResultListEntry>
private listModel: ListElementListModel<CalendarSearchResultListEntry>

constructor({ attrs }: Vnode<CalendarSearchListViewAttrs>) {
this.listModel = attrs.listModel
Expand Down
12 changes: 6 additions & 6 deletions src/calendar-app/calendar/search/view/CalendarSearchViewModel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ListModel } from "../../../../common/misc/ListModel.js"
import { CalendarSearchResultListEntry } from "./CalendarSearchListView.js"
import { SearchRestriction, SearchResult } from "../../../../common/api/worker/search/SearchTypes.js"
import { EntityEventsListener, EventController } from "../../../../common/api/main/EventController.js"
Expand Down Expand Up @@ -47,6 +46,7 @@ import { SearchRouter } from "../../../../common/search/view/SearchRouter.js"
import { locator } from "../../../../common/api/main/CommonLocator.js"
import { CalendarEventsRepository } from "../../../../common/calendar/date/CalendarEventsRepository"
import { getClientOnlyCalendars } from "../../gui/CalendarGuiUtils"
import { ListElementListModel } from "../../../../common/misc/ListElementListModel"

const SEARCH_PAGE_SIZE = 100

Expand All @@ -56,8 +56,8 @@ export enum PaidFunctionResult {
}

export class CalendarSearchViewModel {
private _listModel: ListModel<CalendarSearchResultListEntry>
get listModel(): ListModel<CalendarSearchResultListEntry> {
private _listModel: ListElementListModel<CalendarSearchResultListEntry>
get listModel(): ListElementListModel<CalendarSearchResultListEntry> {
return this._listModel
}

Expand Down Expand Up @@ -539,19 +539,19 @@ export class CalendarSearchViewModel {
this.updateUi()
}

private createList(): ListModel<CalendarSearchResultListEntry> {
private createList(): ListElementListModel<CalendarSearchResultListEntry> {
// since we recreate the list every time we set a new result object,
// we bind the value of result for the lifetime of this list model
// at this point
// note in case of refactor: the fact that the list updates the URL every time it changes
// its state is a major source of complexity and makes everything very order-dependent
return new ListModel<CalendarSearchResultListEntry>({
return new ListElementListModel<CalendarSearchResultListEntry>({
fetch: async (lastFetchedEntity: CalendarSearchResultListEntry, count: number) => {
const startId = lastFetchedEntity == null ? GENERATED_MAX_ID : getElementId(lastFetchedEntity)
const lastResult = this.searchResult
if (lastResult !== this.searchResult) {
console.warn("got a fetch request for outdated results object, ignoring")
// this.searchResults was reassigned, we'll create a new ListModel soon
// this.searchResults was reassigned, we'll create a new ListElementListModel soon
return { items: [], complete: true }
}

Expand Down
4 changes: 2 additions & 2 deletions src/common/gui/base/ListUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { ListElement } from "../../api/common/utils/EntityUtils.js"
import { ListModel } from "../../misc/ListModel.js"
import { Shortcut } from "../../misc/KeyManager.js"
import { Keys } from "../../api/common/TutanotaConstants.js"
import { mapLazily } from "@tutao/tutanota-utils"
import { ListState, MultiselectMode } from "./List.js"
import { Children } from "mithril"
import { isBrowser } from "../../api/common/Env.js"
import { ListElementListModel } from "../../misc/ListElementListModel"

export const ACTION_DISTANCE = 150
export const PageSize = 100
Expand All @@ -29,7 +29,7 @@ export interface ListFetchResult<ElementType> {
complete: boolean
}

export type ListSelectionCallbacks = Pick<ListModel<ListElement>, "selectPrevious" | "selectNext" | "areAllSelected" | "selectAll" | "selectNone">
export type ListSelectionCallbacks = Pick<ListElementListModel<ListElement>, "selectPrevious" | "selectNext" | "areAllSelected" | "selectAll" | "selectNone">

export function listSelectionKeyboardShortcuts(multiselectMode: MultiselectMode, callbacks: () => ListSelectionCallbacks | null): Array<Shortcut> {
const multiselectionEnabled = multiselectMode == MultiselectMode.Enabled ? () => true : () => false
Expand Down
178 changes: 178 additions & 0 deletions src/common/misc/ListElementListModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { ListFilter, ListModel, ListModelConfig } from "./ListModel"
import { getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils"
import { OperationType } from "../api/common/TutanotaConstants"
import Stream from "mithril/stream"
import { ListLoadingState, ListState } from "../gui/base/List"

export type ListElementListModelConfig<ElementType> = Omit<ListModelConfig<ElementType, Id>, "getItemId" | "isSameId">

export class ListElementListModel<ElementType extends ListElement> {
private readonly listModel: ListModel<ElementType, Id>
private readonly config: ListModelConfig<ElementType, Id>

get state(): ListState<ElementType> {
return this.listModel.state
}

get differentItemsSelected(): Stream<ReadonlySet<ElementType>> {
return this.listModel.differentItemsSelected
}

get stateStream(): Stream<ListState<ElementType>> {
return this.listModel.stateStream
}

constructor(config: ListElementListModelConfig<ElementType>) {
this.config = Object.assign({}, config, {
isSameId,
getItemId: getElementId,
})
this.listModel = new ListModel(this.config)
}

async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise<void> {
if (operation === OperationType.CREATE || operation === OperationType.UPDATE) {
// load the element without range checks for now
const entity = await this.config.loadSingle(listId, elementId)
if (!entity) {
return
}

// Wait for any pending loading
return this.listModel.waitLoad(() => {
if (operation === OperationType.CREATE) {
if (this.canCreateEntity(entity)) {
this.listModel.insertLoadedItem(entity)
}
} else if (operation === OperationType.UPDATE) {
this.listModel.updateLoadedItem(entity)
}
})
} else if (operation === OperationType.DELETE) {
// await this.swipeHandler?.animating
await this.listModel.deleteLoadedItem(elementId)
}
}

private canCreateEntity(entity: ElementType): boolean {
if (this.state.loadingStatus !== ListLoadingState.Done) {
return false
}

// new element is in the loaded range or newer than the first element
const lastElement = this.listModel.getLastItem()
return lastElement != null && this.config.sortCompare(entity, lastElement) < 0
}

async loadAndSelect(
itemId: Id,
shouldStop: () => boolean,
finder: (a: ElementType) => boolean = (item) => this.config.isSameId(this.config.getItemId(item), itemId),
): Promise<ElementType | null> {
return this.listModel.loadAndSelect(itemId, shouldStop, finder)
}

isItemSelected(itemId: Id): boolean {
return this.listModel.isItemSelected(itemId)
}

enterMultiselect() {
return this.listModel.enterMultiselect()
}

stopLoading(): void {
return this.listModel.stopLoading()
}

isEmptyAndDone(): boolean {
return this.listModel.isEmptyAndDone()
}

isSelectionEmpty(): boolean {
return this.listModel.isSelectionEmpty()
}

getUnfilteredAsArray(): Array<ElementType> {
return this.listModel.getUnfilteredAsArray()
}

sort() {
return this.listModel.sort()
}

async loadMore() {
return this.listModel.loadMore()
}

async loadAll() {
return this.listModel.loadAll()
}

async retryLoading() {
return this.listModel.retryLoading()
}

onSingleSelection(item: ElementType) {
return this.listModel.onSingleSelection(item)
}

onSingleInclusiveSelection(item: ElementType, clearSelectionOnMultiSelectStart?: boolean) {
return this.listModel.onSingleInclusiveSelection(item, clearSelectionOnMultiSelectStart)
}

onSingleExclusiveSelection(item: ElementType) {
return this.listModel.onSingleExclusiveSelection(item)
}

selectRangeTowards(item: ElementType) {
return this.listModel.selectRangeTowards(item)
}

areAllSelected(): boolean {
return this.listModel.areAllSelected()
}

selectNone() {
return this.listModel.selectNone()
}

selectAll() {
return this.listModel.selectAll()
}

selectPrevious(multiselect: boolean) {
return this.listModel.selectPrevious(multiselect)
}

selectNext(multiselect: boolean) {
return this.listModel.selectNext(multiselect)
}

cancelLoadAll() {
return this.listModel.cancelLoadAll()
}

async loadInitial() {
return this.listModel.loadInitial()
}

reapplyFilter() {
return this.listModel.reapplyFilter()
}

setFilter(filter: ListFilter<ElementType> | null) {
return this.listModel.setFilter(filter)
}

getSelectedAsArray(): Array<ElementType> {
return this.listModel.getSelectedAsArray()
}

isLoadedCompletely(): boolean {
return this.listModel.isLoadedCompletely()
}

updateLoadingStatus(status: ListLoadingState) {
return this.listModel.updateLoadingStatus(status)
}
}
Loading

0 comments on commit ce112b7

Please sign in to comment.