Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor element-specific behavior out of ListModel #8212

Open
wants to merge 1 commit into
base: dev-mail
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading