From edb3e379e831941e31e4187a988f0c028d331807 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 21 Mar 2025 13:52:40 +0100 Subject: [PATCH 01/16] List views --- com.woltlab.wcf/templates/shared_listView.tpl | 95 +++++ .../Core/Api/ListViews/GetItems.ts | 52 +++ ts/WoltLabSuite/Core/Component/ListView.ts | 58 +++ .../Core/Component/ListView/Filter.ts | 140 +++++++ .../Core/Component/ListView/Selection.ts | 300 ++++++++++++++ .../Core/Component/ListView/Sorting.ts | 107 +++++ .../Core/Component/ListView/State.ts | 181 +++++++++ .../Core/Api/ListViews/GetItems.js | 38 ++ .../WoltLabSuite/Core/Component/ListView.js | 37 ++ .../Core/Component/ListView/Filter.js | 100 +++++ .../Core/Component/ListView/Selection.js | 235 +++++++++++ .../Core/Component/ListView/Sorting.js | 83 ++++ .../Core/Component/ListView/State.js | 123 ++++++ .../lib/action/ListViewFilterAction.class.php | 119 ++++++ .../files/lib/bootstrap/com.woltlab.wcf.php | 1 + .../lib/page/AbstractListViewPage.class.php | 89 +++++ .../core/listViews/GetItems.class.php | 83 ++++ .../listView/AbstractListView.class.php | 375 ++++++++++++++++++ .../listView/ListViewSortField.class.php | 19 + .../listView/filter/AbstractFilter.class.php | 50 +++ .../listView/filter/BooleanFilter.class.php | 43 ++ .../listView/filter/IListViewFilter.class.php | 45 +++ .../listView/filter/LabelFilter.class.php | 65 +++ .../listView/filter/TextFilter.class.php | 37 ++ wcfsetup/install/files/style/ui/listView.scss | 57 +++ 25 files changed, 2532 insertions(+) create mode 100644 com.woltlab.wcf/templates/shared_listView.tpl create mode 100644 ts/WoltLabSuite/Core/Api/ListViews/GetItems.ts create mode 100644 ts/WoltLabSuite/Core/Component/ListView.ts create mode 100644 ts/WoltLabSuite/Core/Component/ListView/Filter.ts create mode 100644 ts/WoltLabSuite/Core/Component/ListView/Selection.ts create mode 100644 ts/WoltLabSuite/Core/Component/ListView/Sorting.ts create mode 100644 ts/WoltLabSuite/Core/Component/ListView/State.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/ListViews/GetItems.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Filter.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Selection.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Sorting.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/State.js create mode 100644 wcfsetup/install/files/lib/action/ListViewFilterAction.class.php create mode 100644 wcfsetup/install/files/lib/page/AbstractListViewPage.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/listViews/GetItems.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/AbstractListView.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/ListViewSortField.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/filter/AbstractFilter.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/filter/BooleanFilter.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/filter/IListViewFilter.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/filter/LabelFilter.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/filter/TextFilter.class.php create mode 100644 wcfsetup/install/files/style/ui/listView.scss diff --git a/com.woltlab.wcf/templates/shared_listView.tpl b/com.woltlab.wcf/templates/shared_listView.tpl new file mode 100644 index 00000000000..0d089937558 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_listView.tpl @@ -0,0 +1,95 @@ +
+ {if $view->isSortable() || $view->isFilterable()} +
+ {if $view->isFilterable()} +
+ {foreach from=$view->getActiveFilters() item='value' key='key'} + + {/foreach} +
+ {/if} +
+ {if $view->isSortable()} + + {/if} + {if $view->isFilterable()} +
+ +
+ {/if} +
+
+ {/if} + +
+
countItems()} hidden{/if}> + {unsafe:$view->renderItems()} +
+
+ + + + {*if $view->hasBulkInteractions()} + + {/if*} + + countItems()} hidden{/if}>{lang}wcf.global.noItems{/lang} +
+ + +{*if $view->hasInteractions()} + {unsafe:$view->renderInteractionInitialization()} +{/if} +{if $view->hasBulkInteractions()} + {unsafe:$view->renderBulkInteractionInitialization()} +{/if*} diff --git a/ts/WoltLabSuite/Core/Api/ListViews/GetItems.ts b/ts/WoltLabSuite/Core/Api/ListViews/GetItems.ts new file mode 100644 index 00000000000..2a0fda8e505 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/ListViews/GetItems.ts @@ -0,0 +1,52 @@ +/** + * Gets the items for the rendering of a list view. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + template: string; + pages: number; + totalItems: number; + filterLabels: ArrayLike; +}; + +export async function getItems( + listViewClass: string, + pageNo: number, + sortField: string = "", + sortOrder: string = "ASC", + filters?: Map, + listViewParameters?: Map, +): Promise> { + const url = new URL(`${window.WSC_RPC_API_URL}core/list-views/items`); + url.searchParams.set("listView", listViewClass); + url.searchParams.set("pageNo", pageNo.toString()); + url.searchParams.set("sortField", sortField); + url.searchParams.set("sortOrder", sortOrder); + if (filters) { + filters.forEach((value, key) => { + url.searchParams.set(`filters[${key}]`, value); + }); + } + if (listViewParameters) { + listViewParameters.forEach((value, key) => { + url.searchParams.set(`gridViewParameters[${key}]`, value); + }); + } + + let response: Response; + try { + response = (await prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Component/ListView.ts b/ts/WoltLabSuite/Core/Component/ListView.ts new file mode 100644 index 00000000000..645fd416332 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/ListView.ts @@ -0,0 +1,58 @@ +import State, { StateChangeCause } from "./ListView/State"; +import { trigger as triggerDomChange } from "../Dom/Change/Listener"; +import { setInnerHtml } from "../Dom/Util"; +import { getItems } from "../Api/ListViews/GetItems"; + +export class ListView { + readonly #viewClassName: string; + readonly #viewElement: HTMLElement; + readonly #state: State; + readonly #noItemsNotice: HTMLElement; + + constructor( + viewId: string, + viewClassName: string, + pageNo: number, + baseUrl: string = "", + sortField = "", + sortOrder = "ASC", + ) { + this.#viewClassName = viewClassName; + this.#viewElement = document.getElementById(`${viewId}_items`) as HTMLTableElement; + this.#noItemsNotice = document.getElementById(`${viewId}_noItemsNotice`) as HTMLElement; + + this.#state = this.#setupState(viewId, pageNo, baseUrl, sortField, sortOrder); + } + + #setupState(viewId: string, pageNo: number, baseUrl: string, sortField: string, sortOrder: string): State { + const state = new State(viewId, this.#viewElement, pageNo, baseUrl, sortField, sortOrder); + state.addEventListener("list-view:change", (event) => { + void this.#loadItems(event.detail.source); + }); + /*state.addEventListener("grid-view:get-bulk-interactions", (event) => { + void this.#loadBulkInteractions(event.detail.objectIds); + });*/ + + return state; + } + + async #loadItems(cause: StateChangeCause): Promise { + const response = ( + await getItems( + this.#viewClassName, + this.#state.getPageNo(), + this.#state.getSortField(), + this.#state.getSortOrder(), + this.#state.getActiveFilters(), + //this.#gridViewParameters, + ) + ).unwrap(); + setInnerHtml(this.#viewElement, response.template); + + this.#viewElement.hidden = response.totalItems === 0; + this.#noItemsNotice.hidden = response.totalItems !== 0; + this.#state.updateFromResponse(cause, response.pages, response.filterLabels); + + triggerDomChange(); + } +} diff --git a/ts/WoltLabSuite/Core/Component/ListView/Filter.ts b/ts/WoltLabSuite/Core/Component/ListView/Filter.ts new file mode 100644 index 00000000000..779807bb0e5 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/ListView/Filter.ts @@ -0,0 +1,140 @@ +/** + * Handles the filterung of list views. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import { promiseMutex } from "../../Helper/PromiseMutex"; +import { dialogFactory } from "../Dialog"; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class Filter extends EventTarget { + readonly #filterButton: HTMLButtonElement | null; + readonly #filterPills: HTMLElement | null; + #filters: Map = new Map(); + + constructor(viewId: string) { + super(); + + this.#filterButton = document.getElementById(`${viewId}_filterButton`) as HTMLButtonElement; + this.#filterPills = document.getElementById(`${viewId}_filters`) as HTMLElement; + + this.#setupEventListeners(); + } + + getActiveFilters(): Map { + return new Map(this.#filters); + } + + getQueryParameters(): [string, string][] { + const parameters: [string, string][] = []; + + for (const [key, value] of this.#filters.entries()) { + parameters.push([`filters[${key}]`, value]); + } + + return parameters; + } + + updateFromSearchParams(params: URLSearchParams): void { + this.#filters.clear(); + + params.forEach((value, key) => { + const matches = key.match(/^filters\[([a-z0-9_]+)\]$/i); + if (matches) { + this.#filters.set(matches[1], value); + } + }); + } + + setFilterLabels(labels: ArrayLike): void { + if (this.#filterPills === null) { + return; + } + + this.#filterPills.innerHTML = ""; + if (this.#filters.size === 0) { + return; + } + + for (const key of this.#filters.keys()) { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + const icon = document.createElement("fa-icon"); + icon.setIcon("circle-xmark"); + button.append(icon, labels[key]); + button.addEventListener("click", () => { + this.#removeFilter(key); + }); + + this.#filterPills.append(button); + } + } + + #setupEventListeners(): void { + if (this.#filterButton === null) { + return; + } + + this.#filterButton.addEventListener( + "click", + promiseMutex(() => this.#showFilterDialog()), + ); + + if (this.#filterPills === null) { + return; + } + + const filterButtons = this.#filterPills.querySelectorAll("[data-filter]"); + filterButtons.forEach((button) => { + this.#filters.set(button.dataset.filter!, button.dataset.filterValue!); + button.addEventListener("click", () => { + this.#removeFilter(button.dataset.filter!); + }); + }); + } + + async #showFilterDialog(): Promise { + const url = new URL(this.#filterButton!.dataset.endpoint!); + if (this.#filters) { + this.#filters.forEach((value, key) => { + url.searchParams.set(`filters[${key}]`, value); + }); + } + + const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString()); + + if (ok) { + this.#filters = new Map(Object.entries(result as ArrayLike)); + + this.dispatchEvent(new CustomEvent("list-view:change")); + } + } + + #removeFilter(filter: string): void { + this.#filters.delete(filter); + + this.dispatchEvent(new CustomEvent("list-view:change")); + } +} + +interface FilterEventMap { + "list-view:change": CustomEvent; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface Filter extends EventTarget { + addEventListener: { + ( + type: T, + listener: (this: Filter, ev: FilterEventMap[T]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + } & HTMLElement["addEventListener"]; +} + +export default Filter; diff --git a/ts/WoltLabSuite/Core/Component/ListView/Selection.ts b/ts/WoltLabSuite/Core/Component/ListView/Selection.ts new file mode 100644 index 00000000000..0b1bf090b46 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/ListView/Selection.ts @@ -0,0 +1,300 @@ +/** + * Handles the selection of grid view rows. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import { getStoragePrefix } from "WoltLabSuite/Core/Core"; +import DomUtil from "WoltLabSuite/Core/Dom/Util"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { getPhrase } from "WoltLabSuite/Core/Language"; +import UiDropdownSimple, { getDropdownMenu, setAlignmentById } from "WoltLabSuite/Core/Ui/Dropdown/Simple"; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class Selection extends EventTarget { + readonly #markAll: HTMLInputElement | null = null; + readonly #table: HTMLElement; + readonly #selectionBar: HTMLElement | null = null; + readonly #bulkInteractionButton: HTMLButtonElement | null = null; + #bulkInteractionsPlaceholder: HTMLLIElement | null = null; + #bulkInteractionsLoadingDelay: number | undefined = undefined; + + constructor(gridId: string, table: HTMLElement) { + super(); + + this.#table = table; + + this.#markAll = this.#table.querySelector(".gridView__selectAllRows"); + this.#markAll?.addEventListener("change", () => { + this.#change(this.#markAll!.checked); + }); + + this.#selectionBar = document.getElementById(`${gridId}_selectionBar`) as HTMLElement; + this.#bulkInteractionButton = document.getElementById(`${gridId}_bulkInteractionButton`) as HTMLButtonElement; + this.#bulkInteractionButton?.addEventListener("click", () => { + this.#showBulkInteractionMenu(); + }); + + document.getElementById(`${gridId}_resetSelectionButton`)?.addEventListener("click", () => { + this.resetSelection(); + }); + + wheneverFirstSeen(`#${this.#table.id} .gridView__selectRow`, (checkbox: HTMLInputElement) => { + checkbox.addEventListener("change", () => { + this.#change(); + }); + }); + + this.#restoreSelection(); + } + + refresh(): void { + this.#restoreSelection(); + } + + getSelectedIds(): number[] { + const json = window.localStorage.getItem(this.#getStorageKey()); + if (typeof json !== "string") { + return []; + } + + let selectedIds: number[] = []; + try { + const value = JSON.parse(json); + if (Array.isArray(value)) { + selectedIds = value; + } + } catch { + if (window.ENABLE_DEBUG_MODE) { + console.error("Failed to deserialize the selection.", json); + } + + return []; + } + + return selectedIds; + } + + #change(forceValue?: boolean, skipStorage = false): void { + const checkboxes = Array.from(this.#table.querySelectorAll(".gridView__selectRow")); + if (forceValue === undefined) { + if (this.#markAll !== null) { + const markedCheckboxes = checkboxes.filter((checkbox) => checkbox.checked).length; + if (markedCheckboxes === 0) { + this.#markAll.checked = false; + this.#markAll.indeterminate = false; + } else if (markedCheckboxes === checkboxes.length) { + this.#markAll.checked = true; + this.#markAll.indeterminate = false; + } else { + this.#markAll.checked = false; + this.#markAll.indeterminate = markedCheckboxes > 0 && markedCheckboxes !== checkboxes.length; + } + } + } else { + for (const checkbox of checkboxes) { + checkbox.checked = forceValue; + } + } + + if (!skipStorage) { + this.#saveSelection(checkboxes); + } + + this.#rebuildBulkInteractions(); + this.#updateSelectionBar(); + } + + #saveSelection(checkboxes: HTMLInputElement[]): void { + const selection = new Map(); + checkboxes.forEach((checkbox) => { + const row = checkbox.closest(".gridView__row") as HTMLElement; + const id = parseInt(row.dataset.objectId!); + + selection.set(id, checkbox.checked); + }); + + // We support selection across pages thus we need to preserve the selection + // of objects that are not present on the current page. + const selectedIds = this.getSelectedIds().filter((id) => { + const checked = selection.get(id); + if (checked === undefined) { + // Object does not appear on this page, preserve the id. + return true; + } + + return checked; + }); + + // Add any id that was previously not part of the stored selection. + selection.forEach((checked, id) => { + if (checked && !selectedIds.includes(id)) { + selectedIds.push(id); + } + }); + + window.localStorage.setItem(this.#getStorageKey(), JSON.stringify(selectedIds)); + } + + #restoreSelection(): void { + const selectedIds = this.getSelectedIds(); + + this.#table.querySelectorAll(".gridView__row").forEach((row: HTMLElement) => { + const id = parseInt(row.dataset.objectId!); + if (!selectedIds.includes(id)) { + return; + } + + const checkbox = row.querySelector(".gridView__selectRow"); + if (checkbox) { + checkbox.checked = true; + } + }); + + this.#change(undefined, true); + } + + #getStorageKey(): string { + return getStoragePrefix() + `gridView-${this.#table.id}-selection`; + } + + #updateSelectionBar(): void { + const selectedIds = this.getSelectedIds(); + + if (!this.#selectionBar) { + return; + } + + if (selectedIds.length === 0) { + this.#selectionBar.hidden = true; + return; + } + + this.#selectionBar.hidden = false; + this.#bulkInteractionButton!.textContent = getPhrase("wcf.clipboard.button.numberOfSelectedItems", { + numberOfSelectedItems: selectedIds.length, + }); + } + + #showBulkInteractionMenu(): void { + if (this.#bulkInteractionsPlaceholder !== null) { + return; + } + + this.dispatchEvent( + new CustomEvent("grid-view:get-bulk-interactions", { detail: { objectIds: this.getSelectedIds() } }), + ); + + if (this.#bulkInteractionsLoadingDelay !== undefined) { + window.clearTimeout(this.#bulkInteractionsLoadingDelay); + } + + // Delays the display of the available actions to prevent flicker and to + // smooth out the UX. + this.#bulkInteractionsLoadingDelay = window.setTimeout(() => { + this.#bulkInteractionsLoadingDelay = undefined; + }, 200); + } + + setBulkInteractionContextMenuOptions(options: string): void { + const fragment = DomUtil.createFragmentFromHtml(options); + this.#rebuildBulkInteractions(fragment); + } + + #rebuildBulkInteractions(fragment?: DocumentFragment): void { + if (fragment === undefined && this.#bulkInteractionsPlaceholder === null) { + // The call was made before the menu was shown for the first time. + return; + } + + if (this.#bulkInteractionsLoadingDelay !== undefined && fragment !== undefined) { + // The server has already replied but the delay isn't over yet. + window.setTimeout(() => { + this.#rebuildBulkInteractions(fragment); + }, 20); + + return; + } + + const menuId = this.#bulkInteractionButton!.parentElement!.id; + const menu = getDropdownMenu(menuId); + if (menu === undefined) { + throw new Error("Could not find the dropdown menu for " + this.#bulkInteractionButton!.id); + } + + const dividers = Array.from(menu.querySelectorAll(".dropdownDivider")); + const lastDivider = dividers[dividers.length - 1]; + + if (fragment === undefined) { + while (lastDivider.previousElementSibling !== null) { + lastDivider.previousElementSibling.remove(); + } + + menu.prepend(this.#bulkInteractionsPlaceholder!); + this.#bulkInteractionsPlaceholder = null; + } else { + if (this.#bulkInteractionsPlaceholder === null) { + this.#bulkInteractionsPlaceholder = lastDivider.previousElementSibling as HTMLLIElement; + this.#bulkInteractionsPlaceholder.remove(); + } + + menu.prepend(fragment); + + this.#initBulkInteractions(); + } + + setAlignmentById(menuId); + } + + resetSelection(): void { + if (this.#markAll !== null) { + this.#markAll.checked = false; + this.#markAll.indeterminate = false; + } + + this.#table + .querySelectorAll(".gridView__selectRow") + .forEach((checkbox) => (checkbox.checked = false)); + + window.localStorage.removeItem(this.#getStorageKey()); + + this.#updateSelectionBar(); + } + + #initBulkInteractions(): void { + if (!this.#bulkInteractionButton) { + return; + } + + const dropdown = UiDropdownSimple.getDropdownMenu(this.#bulkInteractionButton.dataset.target!); + dropdown?.querySelectorAll("[data-bulk-interaction]").forEach((element) => { + element.addEventListener("click", () => { + this.#table.dispatchEvent( + new CustomEvent("bulk-interaction", { + detail: element.dataset, + }), + ); + }); + }); + } +} + +interface SelectionEventMap { + "grid-view:get-bulk-interactions": CustomEvent<{ objectIds: number[] }>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface Selection extends EventTarget { + addEventListener: { + ( + type: T, + listener: (this: Selection, ev: SelectionEventMap[T]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + } & HTMLElement["addEventListener"]; +} + +export default Selection; diff --git a/ts/WoltLabSuite/Core/Component/ListView/Sorting.ts b/ts/WoltLabSuite/Core/Component/ListView/Sorting.ts new file mode 100644 index 00000000000..2687e339678 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/ListView/Sorting.ts @@ -0,0 +1,107 @@ +/** + * Handles the sorting of list view items. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class Sorting extends EventTarget { + #defaultSortField: string; + #defaultSortOrder: string; + #sortField: string; + #sortOrder: string; + #dropdownMenu: HTMLElement | undefined; + + constructor(dropdownMenu: HTMLElement | undefined, sortField: string, sortOrder: string) { + super(); + + this.#sortField = sortField; + this.#defaultSortField = sortField; + this.#sortOrder = sortOrder; + this.#defaultSortOrder = sortOrder; + this.#dropdownMenu = dropdownMenu; + + this.#dropdownMenu?.querySelectorAll("[data-sort-id]").forEach((element) => { + element.addEventListener("click", () => { + this.#sort(element.dataset.sortId!); + }); + }); + + this.#renderActiveSorting(); + } + + getSortField(): string { + return this.#sortField; + } + + getSortOrder(): string { + return this.#sortOrder; + } + + getQueryParameters(): [string, string][] { + if (this.#sortField === "") { + return []; + } + + return [ + ["sortField", this.#sortField], + ["sortOrder", this.#sortOrder], + ]; + } + + updateFromSearchParams(params: URLSearchParams): void { + this.#sortField = this.#defaultSortField; + this.#sortOrder = this.#defaultSortOrder; + + params.forEach((value, key) => { + if (key === "sortField") { + this.#sortField = value; + } else if (key === "sortOrder") { + this.#sortOrder = value; + } + }); + } + + #sort(sortField: string): void { + if (this.#sortField == sortField && this.#sortOrder == "ASC") { + this.#sortOrder = "DESC"; + } else { + this.#sortField = sortField; + this.#sortOrder = "ASC"; + } + + this.#renderActiveSorting(); + + this.dispatchEvent(new CustomEvent("list-view:change")); + } + + #renderActiveSorting(): void { + this.#dropdownMenu?.querySelectorAll("[data-sort-id]").forEach((element) => { + element.classList.remove("active", "ASC", "DESC"); + + if (element.dataset.sortId == this.#sortField) { + element.classList.add("active", this.#sortOrder); + } + }); + } +} + +interface SortingEventMap { + "list-view:change": CustomEvent; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface Sorting extends EventTarget { + addEventListener: { + ( + type: T, + listener: (this: Sorting, ev: SortingEventMap[T]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + } & HTMLElement["addEventListener"]; +} + +export default Sorting; diff --git a/ts/WoltLabSuite/Core/Component/ListView/State.ts b/ts/WoltLabSuite/Core/Component/ListView/State.ts new file mode 100644 index 00000000000..fd896886836 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/ListView/State.ts @@ -0,0 +1,181 @@ +/** + * Handles the state of a list view. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import Filter from "./Filter"; +import Selection from "./Selection"; +import Sorting from "./Sorting"; + +export const enum StateChangeCause { + Change, + History, + Pagination, +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class State extends EventTarget { + readonly #baseUrl: string; + readonly #filter: Filter; + readonly #pagination: WoltlabCorePaginationElement; + readonly #selection: Selection; + readonly #sorting: Sorting; + #pageNo: number; + + constructor( + viewId: string, + table: HTMLElement, + pageNo: number, + baseUrl: string, + sortField: string, + sortOrder: string, + ) { + super(); + + this.#baseUrl = baseUrl; + this.#pageNo = pageNo; + + this.#pagination = document.getElementById(`${viewId}_pagination`) as WoltlabCorePaginationElement; + this.#pagination.addEventListener("switchPage", (event: CustomEvent) => { + void this.#switchPage(event.detail, StateChangeCause.Pagination); + }); + + this.#filter = new Filter(viewId); + this.#filter.addEventListener("list-view:change", () => { + this.#switchPage(1, StateChangeCause.Change); + }); + + this.#sorting = new Sorting(document.getElementById(`${viewId}_sorting`) ?? undefined, sortField, sortOrder); + this.#sorting.addEventListener("list-view:change", () => { + this.#switchPage(1, StateChangeCause.Change); + }); + + this.#selection = new Selection(viewId, table); + this.#selection.addEventListener("list-view:get-bulk-interactions", (event) => { + this.dispatchEvent( + new CustomEvent("list-view:get-bulk-interactions", { detail: { objectIds: event.detail.objectIds } }), + ); + }); + + window.addEventListener("popstate", () => { + this.#handlePopState(); + }); + } + + getPageNo(): number { + return this.#pageNo; + } + + getSortField(): string { + return this.#sorting.getSortField(); + } + + getSortOrder(): string { + return this.#sorting.getSortOrder(); + } + + getActiveFilters(): Map { + return this.#filter.getActiveFilters(); + } + + getSelectedIds(): number[] { + return this.#selection.getSelectedIds(); + } + + updateFromResponse(cause: StateChangeCause, count: number, filterLabels: ArrayLike): void { + this.#filter.setFilterLabels(filterLabels); + this.#pagination.count = count; + this.#selection.refresh(); + + if (cause === StateChangeCause.Change || cause === StateChangeCause.Pagination) { + this.#updateQueryString(); + } + } + + #switchPage(pageNo: number, source: StateChangeCause): void { + this.#pagination.page = pageNo; + this.#pageNo = pageNo; + + this.dispatchEvent(new CustomEvent("list-view:change", { detail: { source } })); + } + + #updateQueryString(): void { + if (!this.#baseUrl) { + return; + } + + const url = new URL(this.#baseUrl); + + const parameters: [string, string][] = []; + if (this.#pageNo > 1) { + parameters.push(["pageNo", this.#pageNo.toString()]); + } + + for (const parameter of this.#sorting.getQueryParameters()) { + parameters.push(parameter); + } + + for (const parameter of this.#filter.getQueryParameters()) { + parameters.push(parameter); + } + + if (parameters.length > 0) { + url.search += url.search !== "" ? "&" : "?"; + url.search += new URLSearchParams(parameters).toString(); + } + + window.history.pushState({}, document.title, url.toString()); + } + + #handlePopState(): void { + let pageNo = 1; + + const { searchParams } = new URL(window.location.href); + const value = searchParams.get("pageNo"); + if (value !== null) { + pageNo = parseInt(value); + if (Number.isNaN(pageNo) || pageNo < 1) { + pageNo = 1; + } + } + + this.#filter.updateFromSearchParams(searchParams); + this.#sorting.updateFromSearchParams(searchParams); + + this.#switchPage(pageNo, StateChangeCause.History); + } + + setBulkInteractionContextMenuOptions(options: string): void { + this.#selection.setBulkInteractionContextMenuOptions(options); + } + + resetSelection(): void { + this.#selection.resetSelection(); + } + + refreshSelection(): void { + this.#selection.refresh(); + } +} + +interface StateEventMap { + "list-view:change": CustomEvent<{ source: StateChangeCause }>; + "list-view:get-bulk-interactions": CustomEvent<{ objectIds: number[] }>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface State extends EventTarget { + addEventListener: { + ( + type: T, + listener: (this: State, ev: StateEventMap[T]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + } & HTMLElement["addEventListener"]; +} + +export default State; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/ListViews/GetItems.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/ListViews/GetItems.js new file mode 100644 index 00000000000..9fb75b83a9b --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/ListViews/GetItems.js @@ -0,0 +1,38 @@ +/** + * Gets the items for the rendering of a list view. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getItems = getItems; + async function getItems(listViewClass, pageNo, sortField = "", sortOrder = "ASC", filters, listViewParameters) { + const url = new URL(`${window.WSC_RPC_API_URL}core/list-views/items`); + url.searchParams.set("listView", listViewClass); + url.searchParams.set("pageNo", pageNo.toString()); + url.searchParams.set("sortField", sortField); + url.searchParams.set("sortOrder", sortOrder); + if (filters) { + filters.forEach((value, key) => { + url.searchParams.set(`filters[${key}]`, value); + }); + } + if (listViewParameters) { + listViewParameters.forEach((value, key) => { + url.searchParams.set(`gridViewParameters[${key}]`, value); + }); + } + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js new file mode 100644 index 00000000000..c479173e2c6 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js @@ -0,0 +1,37 @@ +define(["require", "exports", "tslib", "./ListView/State", "../Dom/Change/Listener", "../Dom/Util", "../Api/ListViews/GetItems"], function (require, exports, tslib_1, State_1, Listener_1, Util_1, GetItems_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ListView = void 0; + State_1 = tslib_1.__importDefault(State_1); + class ListView { + #viewClassName; + #viewElement; + #state; + #noItemsNotice; + constructor(viewId, viewClassName, pageNo, baseUrl = "", sortField = "", sortOrder = "ASC") { + this.#viewClassName = viewClassName; + this.#viewElement = document.getElementById(`${viewId}_items`); + this.#noItemsNotice = document.getElementById(`${viewId}_noItemsNotice`); + this.#state = this.#setupState(viewId, pageNo, baseUrl, sortField, sortOrder); + } + #setupState(viewId, pageNo, baseUrl, sortField, sortOrder) { + const state = new State_1.default(viewId, this.#viewElement, pageNo, baseUrl, sortField, sortOrder); + state.addEventListener("list-view:change", (event) => { + void this.#loadItems(event.detail.source); + }); + /*state.addEventListener("grid-view:get-bulk-interactions", (event) => { + void this.#loadBulkInteractions(event.detail.objectIds); + });*/ + return state; + } + async #loadItems(cause) { + const response = (await (0, GetItems_1.getItems)(this.#viewClassName, this.#state.getPageNo(), this.#state.getSortField(), this.#state.getSortOrder(), this.#state.getActiveFilters())).unwrap(); + (0, Util_1.setInnerHtml)(this.#viewElement, response.template); + this.#viewElement.hidden = response.totalItems === 0; + this.#noItemsNotice.hidden = response.totalItems !== 0; + this.#state.updateFromResponse(cause, response.pages, response.filterLabels); + (0, Listener_1.trigger)(); + } + } + exports.ListView = ListView; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Filter.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Filter.js new file mode 100644 index 00000000000..ebe59468274 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Filter.js @@ -0,0 +1,100 @@ +/** + * Handles the filterung of list views. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "../../Helper/PromiseMutex", "../Dialog"], function (require, exports, PromiseMutex_1, Dialog_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Filter = void 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging + class Filter extends EventTarget { + #filterButton; + #filterPills; + #filters = new Map(); + constructor(viewId) { + super(); + this.#filterButton = document.getElementById(`${viewId}_filterButton`); + this.#filterPills = document.getElementById(`${viewId}_filters`); + this.#setupEventListeners(); + } + getActiveFilters() { + return new Map(this.#filters); + } + getQueryParameters() { + const parameters = []; + for (const [key, value] of this.#filters.entries()) { + parameters.push([`filters[${key}]`, value]); + } + return parameters; + } + updateFromSearchParams(params) { + this.#filters.clear(); + params.forEach((value, key) => { + const matches = key.match(/^filters\[([a-z0-9_]+)\]$/i); + if (matches) { + this.#filters.set(matches[1], value); + } + }); + } + setFilterLabels(labels) { + if (this.#filterPills === null) { + return; + } + this.#filterPills.innerHTML = ""; + if (this.#filters.size === 0) { + return; + } + for (const key of this.#filters.keys()) { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + const icon = document.createElement("fa-icon"); + icon.setIcon("circle-xmark"); + button.append(icon, labels[key]); + button.addEventListener("click", () => { + this.#removeFilter(key); + }); + this.#filterPills.append(button); + } + } + #setupEventListeners() { + if (this.#filterButton === null) { + return; + } + this.#filterButton.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#showFilterDialog())); + if (this.#filterPills === null) { + return; + } + const filterButtons = this.#filterPills.querySelectorAll("[data-filter]"); + filterButtons.forEach((button) => { + this.#filters.set(button.dataset.filter, button.dataset.filterValue); + button.addEventListener("click", () => { + this.#removeFilter(button.dataset.filter); + }); + }); + } + async #showFilterDialog() { + const url = new URL(this.#filterButton.dataset.endpoint); + if (this.#filters) { + this.#filters.forEach((value, key) => { + url.searchParams.set(`filters[${key}]`, value); + }); + } + const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(url.toString()); + if (ok) { + this.#filters = new Map(Object.entries(result)); + this.dispatchEvent(new CustomEvent("list-view:change")); + } + } + #removeFilter(filter) { + this.#filters.delete(filter); + this.dispatchEvent(new CustomEvent("list-view:change")); + } + } + exports.Filter = Filter; + exports.default = Filter; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Selection.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Selection.js new file mode 100644 index 00000000000..6d82196e25d --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Selection.js @@ -0,0 +1,235 @@ +/** + * Handles the selection of grid view rows. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Ui/Dropdown/Simple"], function (require, exports, tslib_1, Core_1, Util_1, Selector_1, Language_1, Simple_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Selection = void 0; + Util_1 = tslib_1.__importDefault(Util_1); + Simple_1 = tslib_1.__importStar(Simple_1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging + class Selection extends EventTarget { + #markAll = null; + #table; + #selectionBar = null; + #bulkInteractionButton = null; + #bulkInteractionsPlaceholder = null; + #bulkInteractionsLoadingDelay = undefined; + constructor(gridId, table) { + super(); + this.#table = table; + this.#markAll = this.#table.querySelector(".gridView__selectAllRows"); + this.#markAll?.addEventListener("change", () => { + this.#change(this.#markAll.checked); + }); + this.#selectionBar = document.getElementById(`${gridId}_selectionBar`); + this.#bulkInteractionButton = document.getElementById(`${gridId}_bulkInteractionButton`); + this.#bulkInteractionButton?.addEventListener("click", () => { + this.#showBulkInteractionMenu(); + }); + document.getElementById(`${gridId}_resetSelectionButton`)?.addEventListener("click", () => { + this.resetSelection(); + }); + (0, Selector_1.wheneverFirstSeen)(`#${this.#table.id} .gridView__selectRow`, (checkbox) => { + checkbox.addEventListener("change", () => { + this.#change(); + }); + }); + this.#restoreSelection(); + } + refresh() { + this.#restoreSelection(); + } + getSelectedIds() { + const json = window.localStorage.getItem(this.#getStorageKey()); + if (typeof json !== "string") { + return []; + } + let selectedIds = []; + try { + const value = JSON.parse(json); + if (Array.isArray(value)) { + selectedIds = value; + } + } + catch { + if (window.ENABLE_DEBUG_MODE) { + console.error("Failed to deserialize the selection.", json); + } + return []; + } + return selectedIds; + } + #change(forceValue, skipStorage = false) { + const checkboxes = Array.from(this.#table.querySelectorAll(".gridView__selectRow")); + if (forceValue === undefined) { + if (this.#markAll !== null) { + const markedCheckboxes = checkboxes.filter((checkbox) => checkbox.checked).length; + if (markedCheckboxes === 0) { + this.#markAll.checked = false; + this.#markAll.indeterminate = false; + } + else if (markedCheckboxes === checkboxes.length) { + this.#markAll.checked = true; + this.#markAll.indeterminate = false; + } + else { + this.#markAll.checked = false; + this.#markAll.indeterminate = markedCheckboxes > 0 && markedCheckboxes !== checkboxes.length; + } + } + } + else { + for (const checkbox of checkboxes) { + checkbox.checked = forceValue; + } + } + if (!skipStorage) { + this.#saveSelection(checkboxes); + } + this.#rebuildBulkInteractions(); + this.#updateSelectionBar(); + } + #saveSelection(checkboxes) { + const selection = new Map(); + checkboxes.forEach((checkbox) => { + const row = checkbox.closest(".gridView__row"); + const id = parseInt(row.dataset.objectId); + selection.set(id, checkbox.checked); + }); + // We support selection across pages thus we need to preserve the selection + // of objects that are not present on the current page. + const selectedIds = this.getSelectedIds().filter((id) => { + const checked = selection.get(id); + if (checked === undefined) { + // Object does not appear on this page, preserve the id. + return true; + } + return checked; + }); + // Add any id that was previously not part of the stored selection. + selection.forEach((checked, id) => { + if (checked && !selectedIds.includes(id)) { + selectedIds.push(id); + } + }); + window.localStorage.setItem(this.#getStorageKey(), JSON.stringify(selectedIds)); + } + #restoreSelection() { + const selectedIds = this.getSelectedIds(); + this.#table.querySelectorAll(".gridView__row").forEach((row) => { + const id = parseInt(row.dataset.objectId); + if (!selectedIds.includes(id)) { + return; + } + const checkbox = row.querySelector(".gridView__selectRow"); + if (checkbox) { + checkbox.checked = true; + } + }); + this.#change(undefined, true); + } + #getStorageKey() { + return (0, Core_1.getStoragePrefix)() + `gridView-${this.#table.id}-selection`; + } + #updateSelectionBar() { + const selectedIds = this.getSelectedIds(); + if (!this.#selectionBar) { + return; + } + if (selectedIds.length === 0) { + this.#selectionBar.hidden = true; + return; + } + this.#selectionBar.hidden = false; + this.#bulkInteractionButton.textContent = (0, Language_1.getPhrase)("wcf.clipboard.button.numberOfSelectedItems", { + numberOfSelectedItems: selectedIds.length, + }); + } + #showBulkInteractionMenu() { + if (this.#bulkInteractionsPlaceholder !== null) { + return; + } + this.dispatchEvent(new CustomEvent("grid-view:get-bulk-interactions", { detail: { objectIds: this.getSelectedIds() } })); + if (this.#bulkInteractionsLoadingDelay !== undefined) { + window.clearTimeout(this.#bulkInteractionsLoadingDelay); + } + // Delays the display of the available actions to prevent flicker and to + // smooth out the UX. + this.#bulkInteractionsLoadingDelay = window.setTimeout(() => { + this.#bulkInteractionsLoadingDelay = undefined; + }, 200); + } + setBulkInteractionContextMenuOptions(options) { + const fragment = Util_1.default.createFragmentFromHtml(options); + this.#rebuildBulkInteractions(fragment); + } + #rebuildBulkInteractions(fragment) { + if (fragment === undefined && this.#bulkInteractionsPlaceholder === null) { + // The call was made before the menu was shown for the first time. + return; + } + if (this.#bulkInteractionsLoadingDelay !== undefined && fragment !== undefined) { + // The server has already replied but the delay isn't over yet. + window.setTimeout(() => { + this.#rebuildBulkInteractions(fragment); + }, 20); + return; + } + const menuId = this.#bulkInteractionButton.parentElement.id; + const menu = (0, Simple_1.getDropdownMenu)(menuId); + if (menu === undefined) { + throw new Error("Could not find the dropdown menu for " + this.#bulkInteractionButton.id); + } + const dividers = Array.from(menu.querySelectorAll(".dropdownDivider")); + const lastDivider = dividers[dividers.length - 1]; + if (fragment === undefined) { + while (lastDivider.previousElementSibling !== null) { + lastDivider.previousElementSibling.remove(); + } + menu.prepend(this.#bulkInteractionsPlaceholder); + this.#bulkInteractionsPlaceholder = null; + } + else { + if (this.#bulkInteractionsPlaceholder === null) { + this.#bulkInteractionsPlaceholder = lastDivider.previousElementSibling; + this.#bulkInteractionsPlaceholder.remove(); + } + menu.prepend(fragment); + this.#initBulkInteractions(); + } + (0, Simple_1.setAlignmentById)(menuId); + } + resetSelection() { + if (this.#markAll !== null) { + this.#markAll.checked = false; + this.#markAll.indeterminate = false; + } + this.#table + .querySelectorAll(".gridView__selectRow") + .forEach((checkbox) => (checkbox.checked = false)); + window.localStorage.removeItem(this.#getStorageKey()); + this.#updateSelectionBar(); + } + #initBulkInteractions() { + if (!this.#bulkInteractionButton) { + return; + } + const dropdown = Simple_1.default.getDropdownMenu(this.#bulkInteractionButton.dataset.target); + dropdown?.querySelectorAll("[data-bulk-interaction]").forEach((element) => { + element.addEventListener("click", () => { + this.#table.dispatchEvent(new CustomEvent("bulk-interaction", { + detail: element.dataset, + })); + }); + }); + } + } + exports.Selection = Selection; + exports.default = Selection; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Sorting.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Sorting.js new file mode 100644 index 00000000000..ee5fd187ba2 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/Sorting.js @@ -0,0 +1,83 @@ +/** + * Handles the sorting of list view items. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Sorting = void 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging + class Sorting extends EventTarget { + #defaultSortField; + #defaultSortOrder; + #sortField; + #sortOrder; + #dropdownMenu; + constructor(dropdownMenu, sortField, sortOrder) { + super(); + this.#sortField = sortField; + this.#defaultSortField = sortField; + this.#sortOrder = sortOrder; + this.#defaultSortOrder = sortOrder; + this.#dropdownMenu = dropdownMenu; + this.#dropdownMenu?.querySelectorAll("[data-sort-id]").forEach((element) => { + element.addEventListener("click", () => { + this.#sort(element.dataset.sortId); + }); + }); + this.#renderActiveSorting(); + } + getSortField() { + return this.#sortField; + } + getSortOrder() { + return this.#sortOrder; + } + getQueryParameters() { + if (this.#sortField === "") { + return []; + } + return [ + ["sortField", this.#sortField], + ["sortOrder", this.#sortOrder], + ]; + } + updateFromSearchParams(params) { + this.#sortField = this.#defaultSortField; + this.#sortOrder = this.#defaultSortOrder; + params.forEach((value, key) => { + if (key === "sortField") { + this.#sortField = value; + } + else if (key === "sortOrder") { + this.#sortOrder = value; + } + }); + } + #sort(sortField) { + if (this.#sortField == sortField && this.#sortOrder == "ASC") { + this.#sortOrder = "DESC"; + } + else { + this.#sortField = sortField; + this.#sortOrder = "ASC"; + } + this.#renderActiveSorting(); + this.dispatchEvent(new CustomEvent("list-view:change")); + } + #renderActiveSorting() { + this.#dropdownMenu?.querySelectorAll("[data-sort-id]").forEach((element) => { + element.classList.remove("active", "ASC", "DESC"); + if (element.dataset.sortId == this.#sortField) { + element.classList.add("active", this.#sortOrder); + } + }); + } + } + exports.Sorting = Sorting; + exports.default = Sorting; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/State.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/State.js new file mode 100644 index 00000000000..134b3323bf6 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView/State.js @@ -0,0 +1,123 @@ +/** + * Handles the state of a list view. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"], function (require, exports, tslib_1, Filter_1, Selection_1, Sorting_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.State = void 0; + Filter_1 = tslib_1.__importDefault(Filter_1); + Selection_1 = tslib_1.__importDefault(Selection_1); + Sorting_1 = tslib_1.__importDefault(Sorting_1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging + class State extends EventTarget { + #baseUrl; + #filter; + #pagination; + #selection; + #sorting; + #pageNo; + constructor(viewId, table, pageNo, baseUrl, sortField, sortOrder) { + super(); + this.#baseUrl = baseUrl; + this.#pageNo = pageNo; + this.#pagination = document.getElementById(`${viewId}_pagination`); + this.#pagination.addEventListener("switchPage", (event) => { + void this.#switchPage(event.detail, 2 /* StateChangeCause.Pagination */); + }); + this.#filter = new Filter_1.default(viewId); + this.#filter.addEventListener("list-view:change", () => { + this.#switchPage(1, 0 /* StateChangeCause.Change */); + }); + this.#sorting = new Sorting_1.default(document.getElementById(`${viewId}_sorting`) ?? undefined, sortField, sortOrder); + this.#sorting.addEventListener("list-view:change", () => { + this.#switchPage(1, 0 /* StateChangeCause.Change */); + }); + this.#selection = new Selection_1.default(viewId, table); + this.#selection.addEventListener("list-view:get-bulk-interactions", (event) => { + this.dispatchEvent(new CustomEvent("list-view:get-bulk-interactions", { detail: { objectIds: event.detail.objectIds } })); + }); + window.addEventListener("popstate", () => { + this.#handlePopState(); + }); + } + getPageNo() { + return this.#pageNo; + } + getSortField() { + return this.#sorting.getSortField(); + } + getSortOrder() { + return this.#sorting.getSortOrder(); + } + getActiveFilters() { + return this.#filter.getActiveFilters(); + } + getSelectedIds() { + return this.#selection.getSelectedIds(); + } + updateFromResponse(cause, count, filterLabels) { + this.#filter.setFilterLabels(filterLabels); + this.#pagination.count = count; + this.#selection.refresh(); + if (cause === 0 /* StateChangeCause.Change */ || cause === 2 /* StateChangeCause.Pagination */) { + this.#updateQueryString(); + } + } + #switchPage(pageNo, source) { + this.#pagination.page = pageNo; + this.#pageNo = pageNo; + this.dispatchEvent(new CustomEvent("list-view:change", { detail: { source } })); + } + #updateQueryString() { + if (!this.#baseUrl) { + return; + } + const url = new URL(this.#baseUrl); + const parameters = []; + if (this.#pageNo > 1) { + parameters.push(["pageNo", this.#pageNo.toString()]); + } + for (const parameter of this.#sorting.getQueryParameters()) { + parameters.push(parameter); + } + for (const parameter of this.#filter.getQueryParameters()) { + parameters.push(parameter); + } + if (parameters.length > 0) { + url.search += url.search !== "" ? "&" : "?"; + url.search += new URLSearchParams(parameters).toString(); + } + window.history.pushState({}, document.title, url.toString()); + } + #handlePopState() { + let pageNo = 1; + const { searchParams } = new URL(window.location.href); + const value = searchParams.get("pageNo"); + if (value !== null) { + pageNo = parseInt(value); + if (Number.isNaN(pageNo) || pageNo < 1) { + pageNo = 1; + } + } + this.#filter.updateFromSearchParams(searchParams); + this.#sorting.updateFromSearchParams(searchParams); + this.#switchPage(pageNo, 1 /* StateChangeCause.History */); + } + setBulkInteractionContextMenuOptions(options) { + this.#selection.setBulkInteractionContextMenuOptions(options); + } + resetSelection() { + this.#selection.resetSelection(); + } + refreshSelection() { + this.#selection.refresh(); + } + } + exports.State = State; + exports.default = State; +}); diff --git a/wcfsetup/install/files/lib/action/ListViewFilterAction.class.php b/wcfsetup/install/files/lib/action/ListViewFilterAction.class.php new file mode 100644 index 00000000000..4ad01c478f5 --- /dev/null +++ b/wcfsetup/install/files/lib/action/ListViewFilterAction.class.php @@ -0,0 +1,119 @@ + + * @since 6.2 + */ +final class ListViewFilterAction implements RequestHandlerInterface +{ + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + listView: string, + filters: string[], + listViewParameters: string[] + } + EOT + ); + + if (!\is_subclass_of($parameters['listView'], AbstractListView::class)) { + throw new UserInputException('listView', 'invalid'); + } + + /** @var AbstractListView> $view */ + $view = new $parameters['listView'](...$parameters['listViewParameters']); + + if (!$view->isAccessible()) { + throw new PermissionDeniedException(); + } + + if (!$view->isFilterable()) { + throw new IllegalLinkException(); + } + + $form = $this->getForm($view, $parameters['filters']); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + $rawData = $form->getData(); + $data = $rawData['data']; + // This code is required to bypass the strange behavior of the LabelFormField. + if (!empty($rawData['labelIDs'])) { + foreach ($rawData['labelIDs'] as $groupID => $value) { + $data['labelIDs' . $groupID] = $value; + } + } + + foreach ($data as $key => $value) { + if ($value === '' || $value === null) { + unset($data[$key]); + } + } + + return new JsonResponse([ + 'result' => $data + ]); + } else { + throw new \LogicException('Unreachable'); + } + } + + /** + * @param AbstractListView> $listView + * @param array $values + */ + private function getForm(AbstractListView $listView, array $values): Psr15DialogForm + { + $form = new Psr15DialogForm( + static::class, + WCF::getLanguage()->get('wcf.global.filter') + ); + + foreach ($listView->getAvailableFilters() as $filter) { + $formField = $filter->getFormField(); + + if (isset($values[$filter->getID()])) { + $formField->value($values[$filter->getID()]); + } + + $form->appendChild($formField); + } + + $form->markRequiredFields(false); + $form->build(); + + return $form; + } +} diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index aafa6c163e4..2a3a74a6bbb 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -140,6 +140,7 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\gridViews\GetRows()); $event->register(new \wcf\system\endpoint\controller\core\gridViews\GetRow()); $event->register(new \wcf\system\endpoint\controller\core\cronjobs\logs\ClearLogs()); + $event->register(new \wcf\system\endpoint\controller\core\listViews\GetItems()); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions()); $event->register(new \wcf\system\endpoint\controller\core\messages\RenderQuote()); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMessageAuthor()); diff --git a/wcfsetup/install/files/lib/page/AbstractListViewPage.class.php b/wcfsetup/install/files/lib/page/AbstractListViewPage.class.php new file mode 100644 index 00000000000..3dc321124a7 --- /dev/null +++ b/wcfsetup/install/files/lib/page/AbstractListViewPage.class.php @@ -0,0 +1,89 @@ + + * @since 6.2 + */ +abstract class AbstractListViewPage extends AbstractPage +{ + protected AbstractListView $listView; + protected int $pageNo = 1; + protected string $sortField = ''; + protected string $sortOrder = ''; + protected array $filters = []; + + #[\Override] + public function readParameters() + { + parent::readParameters(); + + if (isset($_REQUEST['pageNo'])) { + $this->pageNo = \intval($_REQUEST['pageNo']); + } + if (isset($_REQUEST['sortField'])) { + $this->sortField = $_REQUEST['sortField']; + } + if (isset($_REQUEST['sortOrder']) && ($_REQUEST['sortOrder'] === 'ASC' || $_REQUEST['sortOrder'] === 'DESC')) { + $this->sortOrder = $_REQUEST['sortOrder']; + } + if (isset($_REQUEST['filters']) && \is_array($_REQUEST['filters'])) { + $this->filters = $_REQUEST['filters']; + } + } + + #[\Override] + public function readData() + { + parent::readData(); + + $this->initListView(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'listView' => $this->listView, + ]); + } + + protected function initListView(): void + { + $this->listView = $this->createListView(); + if (!$this->listView->isAccessible()) { + throw new PermissionDeniedException(); + } + + if ($this->sortField) { + $this->listView->setSortField($this->sortField); + } + if ($this->sortOrder) { + $this->listView->setSortOrder($this->sortOrder); + } + if ($this->filters !== []) { + $this->listView->setActiveFilters($this->filters); + } + if ($this->pageNo != 1) { + $this->listView->setPageNo($this->pageNo); + } + $this->listView->setBaseUrl(LinkHandler::getInstance()->getControllerLink(static::class)); + } + + /** + * Returns the list view instance for the rendering of this page. + */ + protected abstract function createListView(): AbstractListView; +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/listViews/GetItems.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/listViews/GetItems.class.php new file mode 100644 index 00000000000..53ffe73647e --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/listViews/GetItems.class.php @@ -0,0 +1,83 @@ + + * @since 6.2 + */ +#[GetRequest('/core/list-views/items')] +final class GetItems implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, GetItemsParameters::class); + + if (!\is_subclass_of($parameters->listView, AbstractListView::class)) { + throw new UserInputException('listView', 'invalid'); + } + + $view = new $parameters->listView(...$parameters->listViewParameters); + // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue + \assert($view instanceof AbstractListView); + + if (!$view->isAccessible()) { + throw new PermissionDeniedException(); + } + + $view->setPageNo($parameters->pageNo); + if ($parameters->sortField) { + $view->setSortField($parameters->sortField); + } + if ($parameters->sortOrder) { + $view->setSortOrder($parameters->sortOrder); + } + + if ($parameters->filters !== []) { + $view->setActiveFilters($parameters->filters); + } + + $filterLabels = []; + foreach (\array_keys($parameters->filters) as $key) { + $filterLabels[$key] = $view->getFilterLabel($key); + } + + return new JsonResponse([ + 'template' => $view->renderItems(), + 'pages' => $view->countPages(), + 'totalItems' => $view->countItems(), + 'filterLabels' => $filterLabels, + ]); + } +} + +/** @internal */ +final class GetItemsParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $listView, + public readonly int $pageNo, + public readonly string $sortField, + public readonly string $sortOrder, + /** @var string[] */ + public readonly array $filters, + /** @var string[] */ + public readonly array $listViewParameters, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/listView/AbstractListView.class.php b/wcfsetup/install/files/lib/system/listView/AbstractListView.class.php new file mode 100644 index 00000000000..00bd716c3e7 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/AbstractListView.class.php @@ -0,0 +1,375 @@ + + */ + private array $activeFilters = []; + + /** + * @var array + */ + private array $availableSortFields = []; + + /** + * @var array + */ + private array $availableFilters = []; + + /** + * @var DatabaseObject[] + */ + private array $objects; + + /** + * Returns the number of items per page. + */ + public function getItemsPerPage(): int + { + return $this->itemsPerPage; + } + + /** + * Sets the number of items per page. + */ + public function setItemsPerPage(int $itemsPerPage): void + { + $this->itemsPerPage = $itemsPerPage; + } + + /** + * Sets the sort field of the list view. + */ + public function setSortField(string $sortField): void + { + if (!isset($this->availableSortFields[$sortField])) { + throw new \InvalidArgumentException("Invalid value '{$sortField}' as sort field given."); + } + + $this->sortField = $sortField; + } + + /** + * Sets the sort order of the list view. + */ + public function setSortOrder(string $sortOrder): void + { + if ($sortOrder !== 'ASC' && $sortOrder !== 'DESC') { + throw new \InvalidArgumentException("Invalid value '{$sortOrder}' as sort order given."); + } + + $this->sortOrder = $sortOrder; + } + + /** + * Returns the sort field of the list view. + */ + public function getSortField(): string + { + return $this->sortField; + } + + /** + * Returns the sort order of the list view. + */ + public function getSortOrder(): string + { + return $this->sortOrder; + } + + /** + * Returns the page number. + */ + public function getPageNo(): int + { + return $this->pageNo; + } + + /** + * Sets the page number. + */ + public function setPageNo(int $pageNo): void + { + $this->pageNo = $pageNo; + } + + /** + * Sets the active filter values. + */ + public function setActiveFilters(array $filters): void + { + $this->activeFilters = $filters; + } + + /** + * Returns the active filter values. + */ + public function getActiveFilters(): array + { + return $this->activeFilters; + } + + /** + * Sets the base url of the list view. + */ + public function setBaseUrl(string $url): void + { + $this->baseUrl = $url; + } + + /** + * Returns the base url of the list view. + */ + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + /** + * Initializes the database object list. + */ + protected function initObjectList(): void + { + $this->objectList = $this->createObjectList(); + $this->objectList->sqlLimit = $this->getItemsPerPage(); + $this->objectList->sqlOffset = ($this->getPageNo() - 1) * $this->getItemsPerPage(); + if ($this->getSortField()) { + $sortFieldObject = $this->availableSortFields[$this->getSortField()]; + + if ($sortFieldObject->sortByDatabaseColumn) { + $this->objectList->sqlOrderBy = $sortFieldObject->sortByDatabaseColumn . ' ' . $this->getSortOrder(); + } else { + $this->objectList->sqlOrderBy = $this->objectList->getDatabaseTableAlias() . + '.' . $sortFieldObject->id . ' ' . $this->getSortOrder(); + } + + $this->objectList->sqlOrderBy .= ',' . $this->objectList->getDatabaseTableAlias() . + '.' . $this->objectList->getDatabaseTableIndexName() . ' ' . $this->getSortOrder(); + } + /*if ($this->getObjectIDFilter() !== null) { + $this->objectList->getConditionBuilder()->add( + $this->objectList->getDatabaseTableAlias() . '.' . $this->objectList->getDatabaseTableIndexName() . ' = ?', + [$this->getObjectIDFilter()] + ); + }*/ + $this->applyFilters(); + /*$this->validate(); + $this->fireInitializedEvent(); + */ + } + + /** + * Applies the active filters. + */ + protected function applyFilters(): void + { + foreach ($this->getActiveFilters() as $key => $value) { + if (!isset($this->availableFilters[$key])) { + throw new \LogicException("Unknown filter '" . $key . "'"); + } + + $this->availableFilters[$key]->applyFilter($this->getObjectList(), $value); + } + } + + /** + * Returns the items for the active page. + * + * @return DatabaseObject[] + */ + public function getItems(): array + { + if (!isset($this->objects)) { + $this->getObjectList()->readObjects(); + $this->objects = $this->getObjectList()->getObjects(); + } + + return $this->objects; + } + + /** + * Returns the total number of items. + */ + public function countItems(): int + { + if (!isset($this->objectCount)) { + $this->objectCount = $this->getObjectList()->countObjects(); + } + + return $this->objectCount; + } + + /** + * Returns the database object list. + */ + public function getObjectList(): DatabaseObjectList + { + if (!isset($this->objectList)) { + $this->initObjectList(); + } + + return $this->objectList; + } + + /** + * Counts the pages of the grid view. + */ + public function countPages(): int + { + return (int)\ceil($this->countItems() / $this->getItemsPerPage()); + } + + /** + * Returns the class name of this list view. + */ + public function getClassName(): string + { + return \get_class($this); + } + + /** + * Returns true, if this list view is accessible for the active user. + */ + public function isAccessible(): bool + { + return true; + } + + /** + * Returns the id of this list view. + */ + public function getID(): string + { + $classNamePieces = \explode('\\', static::class); + + return \implode('-', $classNamePieces); + } + + /** + * Returns true, if the list view is filterable. + */ + public function isFilterable(): bool + { + return $this->availableFilters !== []; + } + + /** + * Returns the endpoint for the filter action. + */ + public function getFilterActionEndpoint(): string + { + return LinkHandler::getInstance()->getControllerLink( + ListViewFilterAction::class, + ['listView' => \get_class($this), 'listViewParameters' => $this->getParameters()] + ); + } + + /** + * Returns true, if the list view is sortable. + */ + public function isSortable(): bool + { + return $this->availableSortFields !== []; + } + + public function addAvailableSortField(ListViewSortField $sortField): void + { + $this->availableSortFields[$sortField->id] = $sortField; + } + + /** + * @param array + */ + public function addAvailableSortFields(array $sortFields): void + { + foreach ($sortFields as $sortField) { + $this->addAvailableSortField($sortField); + } + } + + /** + * @return ListViewSortField[] + */ + public function getAvailableSortFields(): array + { + return $this->availableSortFields; + } + + public function addAvailableFilter(IListViewFilter $filter): void + { + $this->availableFilters[$filter->getId()] = $filter; + } + + /** + * @param IListViewFilter[] $filters + */ + public function addAvailableFilters(array $filters): void + { + foreach ($filters as $filter) { + $this->addAvailableFilter($filter); + } + } + + /** + * @return array + */ + public function getAvailableFilters(): array + { + return $this->availableFilters; + } + + /** + * Gets the additional parameters of the list view. + * + * @return mixed[] + */ + public function getParameters(): array + { + return []; + } + + /** + * Returns the label for the given filter. + */ + public function getFilterLabel(string $id): string + { + if (!isset($this->availableFilters[$id])) { + throw new \LogicException("Unknown filter '" . $id . "'."); + } + + if (!isset($this->activeFilters[$id])) { + throw new \LogicException("No value for filter '" . $id . "' found."); + } + + $value = $this->availableFilters[$id]->renderValue($this->activeFilters[$id]); + + return $this->availableFilters[$id]->getLabel() . ($value !== '' ? ': ' . $value : ''); + } + + public function render(): string + { + return WCF::getTPL()->render('wcf', 'shared_listView', ['view' => $this]); + } + + protected abstract function createObjectList(): DatabaseObjectList; + + public abstract function renderItems(): string; +} diff --git a/wcfsetup/install/files/lib/system/listView/ListViewSortField.class.php b/wcfsetup/install/files/lib/system/listView/ListViewSortField.class.php new file mode 100644 index 00000000000..3535e15f160 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/ListViewSortField.class.php @@ -0,0 +1,19 @@ +get($this->languageItem); + } +} diff --git a/wcfsetup/install/files/lib/system/listView/filter/AbstractFilter.class.php b/wcfsetup/install/files/lib/system/listView/filter/AbstractFilter.class.php new file mode 100644 index 00000000000..4a9dbd82fda --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/filter/AbstractFilter.class.php @@ -0,0 +1,50 @@ + + * @since 6.2 + */ +abstract class AbstractFilter implements IListViewFilter +{ + public function __construct( + protected readonly string $id, + protected readonly string $languageItem, + protected readonly string $databaseColumn = '' + ) {} + + #[\Override] + public function renderValue(string $value): string + { + return $value; + } + + #[\Override] + public function getId(): string + { + return $this->id; + } + + #[\Override] + public function getLabel(): string + { + return WCF::getLanguage()->get($this->languageItem); + } + + /** + * @param DatabaseObjectList $list + */ + protected function getDatabaseColumnName(DatabaseObjectList $list): string + { + return ($this->databaseColumn ?: $list->getDatabaseTableAlias() . '.' . $this->id); + } +} diff --git a/wcfsetup/install/files/lib/system/listView/filter/BooleanFilter.class.php b/wcfsetup/install/files/lib/system/listView/filter/BooleanFilter.class.php new file mode 100644 index 00000000000..652ebdb1d31 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/filter/BooleanFilter.class.php @@ -0,0 +1,43 @@ + + * @since 6.2 + */ +class BooleanFilter extends AbstractFilter +{ + #[\Override] + public function getFormField(): AbstractFormField + { + return CheckboxFormField::create($this->id) + ->label($this->languageItem) + ->nullable(); + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $value): void + { + $columnName = $this->getDatabaseColumnName($list); + + $list->getConditionBuilder()->add( + "{$columnName} = ?", + [1] + ); + } + + #[\Override] + public function renderValue(string $value): string + { + return ''; + } +} diff --git a/wcfsetup/install/files/lib/system/listView/filter/IListViewFilter.class.php b/wcfsetup/install/files/lib/system/listView/filter/IListViewFilter.class.php new file mode 100644 index 00000000000..0a951114fb9 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/filter/IListViewFilter.class.php @@ -0,0 +1,45 @@ + + * @since 6.2 + */ +interface IListViewFilter +{ + /** + * Returns the form field for the input of this filter. + */ + public function getFormField(): AbstractFormField; + + /** + * Applies the filter to the given database object list. + * + * @param DatabaseObjectList $list + */ + public function applyFilter(DatabaseObjectList $list, string $value): void; + + /** + * Renders the filter value in a human readable format. + */ + public function renderValue(string $value): string; + + /** + * Returns the id of this filter. + */ + public function getId(): string; + + /** + * Returns the label of this filter. + */ + public function getLabel(): string; +} diff --git a/wcfsetup/install/files/lib/system/listView/filter/LabelFilter.class.php b/wcfsetup/install/files/lib/system/listView/filter/LabelFilter.class.php new file mode 100644 index 00000000000..591fbc01132 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/filter/LabelFilter.class.php @@ -0,0 +1,65 @@ + + * @since 6.2 + */ +class LabelFilter extends AbstractFilter +{ + public function __construct( + private readonly ViewableLabelGroup $labelGroup, + private readonly int $objectTypeID, + string $id, + string $databaseColumn = '' + ) { + parent::__construct($id, '', $databaseColumn); + } + + #[\Override] + public function getFormField(): AbstractFormField + { + return LabelFormField::create($this->id) + ->objectProperty('labelIDs') + ->labelGroup($this->labelGroup); + } + + #[\Override] + public function getLabel(): string + { + return $this->labelGroup->getTitle(); + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $value): void + { + $list->getConditionBuilder()->add( + "{$list->getDatabaseTableAlias()}.{$list->getDatabaseTableIndexName()} IN ( + SELECT objectID + FROM wcf1_label_object + WHERE objectTypeID = ? + AND labelID = ? + )", + [ + $this->objectTypeID, + $value, + ] + ); + } + + #[\Override] + public function renderValue(string $value): string + { + return $this->labelGroup->getLabel($value)->getTitle(); + } +} diff --git a/wcfsetup/install/files/lib/system/listView/filter/TextFilter.class.php b/wcfsetup/install/files/lib/system/listView/filter/TextFilter.class.php new file mode 100644 index 00000000000..d39fb0924e9 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/filter/TextFilter.class.php @@ -0,0 +1,37 @@ + + * @since 6.2 + */ +class TextFilter extends AbstractFilter +{ + #[\Override] + public function getFormField(): AbstractFormField + { + return TextFormField::create($this->id) + ->label($this->languageItem); + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $value): void + { + $columnName = $this->getDatabaseColumnName($list); + + $list->getConditionBuilder()->add( + "{$columnName} LIKE ?", + ['%' . WCF::getDB()->escapeLikeValue($value) . '%'] + ); + } +} diff --git a/wcfsetup/install/files/style/ui/listView.scss b/wcfsetup/install/files/style/ui/listView.scss new file mode 100644 index 00000000000..77653f40567 --- /dev/null +++ b/wcfsetup/install/files/style/ui/listView.scss @@ -0,0 +1,57 @@ +.listView__header { + display: flex; + flex-direction: row; + justify-content: space-between; + padding-bottom: 10px; + gap: 5px; +} + +.listView__filters { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.listView__header__buttons { + display: flex; + gap: 5px; +} + +.listView__sorting__button.ASC::after, +.listView__sorting__button.DESC::after { + display: inline-block; + margin-left: 5px; +} + +.listView__sorting__button.ASC::after { + // 2191 = UPWARDS ARROW + content: "\2191"; +} + +.listView__sorting__button.DESC::after { + // 2193 = DOWNWARDS ARROW + content: "\2193"; +} + +.listView__footer { + background-color: var(--wcfContentContainerBackground); + bottom: 0; + display: flex; + gap: 10px; + position: sticky; + z-index: 2; +} + +.listView__pagination { + margin-top: 10px; + margin-bottom: 10px; + margin-inline-start: auto; +} + +.listView { + position: relative; +} + +.listView .info { + margin-top: 0; +} From a9bb2ed0e92226afdadc7392786e8da56690640a Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 21 Mar 2025 13:53:07 +0100 Subject: [PATCH 02/16] Migrate `ArticleListPage` to list view --- com.woltlab.wcf/templates/articleList.tpl | 126 ++------- .../templates/articleListItems.tpl | 2 +- .../templates/categoryArticleList.tpl | 92 ++----- .../templates/unreadArticleList.tpl | 82 ------ .../templates/watchedArticleList.tpl | 80 ------ .../files/lib/page/ArticleListPage.class.php | 252 ++---------------- .../page/CategoryArticleListPage.class.php | 84 ++---- .../lib/page/UnreadArticleListPage.class.php | 54 +--- .../lib/page/WatchedArticleListPage.class.php | 42 +-- .../listView/user/ArticleListView.class.php | 162 +++++++++++ .../user/CategoryArticleListView.class.php | 54 ++++ wcfsetup/install/lang/de.xml | 2 + wcfsetup/install/lang/en.xml | 2 + 13 files changed, 314 insertions(+), 720 deletions(-) delete mode 100644 com.woltlab.wcf/templates/unreadArticleList.tpl delete mode 100644 com.woltlab.wcf/templates/watchedArticleList.tpl create mode 100644 wcfsetup/install/files/lib/system/listView/user/ArticleListView.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/user/CategoryArticleListView.class.php diff --git a/com.woltlab.wcf/templates/articleList.tpl b/com.woltlab.wcf/templates/articleList.tpl index b877a507615..578ed16b905 100644 --- a/com.woltlab.wcf/templates/articleList.tpl +++ b/com.woltlab.wcf/templates/articleList.tpl @@ -1,139 +1,47 @@ {capture append='headContent'} - {if $pageNo < $pages} - + {if $listView->getPageNo() < $listView->countPages()} + {/if} - {if $pageNo > 1} - + {if $listView->getPageNo() > 1} + {/if} - {if $__wcf->getUser()->userID} - + {if $__wcf->user->userID} + {else} {/if} {/capture} {capture assign='contentHeaderNavigation'} - {if $__wcf->getSession()->getPermission('admin.content.article.canManageArticle') || $__wcf->getSession()->getPermission('admin.content.article.canManageOwnArticles') || $__wcf->getSession()->getPermission('admin.content.article.canContributeArticle')} + {if $canManageArticles} {if $availableLanguages|count > 1} -
  • {icon name='plus'} {lang}wcf.acp.article.add{/lang}
  • +
  • {else}
  • {icon name='plus'} {lang}wcf.acp.article.add{/lang}
  • {/if} {/if} {/capture} -{capture assign='sidebarRight'} - {if !$labelGroups|empty} -
    -
    -

    {lang}wcf.label.label{/lang}

    - -
    -
    - {include file='__labelSelection'} -
    -
    - -
    -
    -
    -
    - - - {/if} -{/capture} - -{assign var='additionalLinkParameters' value=''} -{if $user}{capture append='additionalLinkParameters'}&userID={@$user->userID}{/capture}{/if} -{if $labelIDs|count}{capture append='additionalLinkParameters'}{foreach from=$labelIDs key=labelGroupID item=labelID}&labelIDs[{@$labelGroupID}]={@$labelID}{/foreach}{/capture}{/if} - -{capture assign='contentInteractionPagination'} - {pages print=true assign='pagesLinks' controller='ArticleList' link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder$additionalLinkParameters"} -{/capture} - {capture assign='contentInteractionButtons'} - {if $__wcf->user->userID} {/if} {/capture} {capture assign='contentInteractionDropdownItems'} -
  • {lang}wcf.global.button.rss{/lang}
  • + {if $__wcf->user->userID} +
  • {lang}wcf.global.button.rss{/lang}
  • + {else} +
  • {lang}wcf.global.button.rss{/lang}
  • + {/if} {/capture} {include file='header'} -{if $objects|count} -
    - {include file='articleListItems'} -
    -{else} - {lang}wcf.global.noItems{/lang} -{/if} - -
    - {hascontent} -
    - {content}{@$pagesLinks}{/content} -
    - {/hascontent} - - {hascontent} - - {/hascontent} -
    +
    + {unsafe:$listView->render()} +
    {if $__wcf->user->userID} {/if} -{if $__wcf->getSession()->getPermission('admin.content.article.canManageArticle') || $__wcf->getSession()->getPermission('admin.content.article.canManageOwnArticles') || $__wcf->getSession()->getPermission('admin.content.article.canContributeArticle')} +{if $canManageArticles} {include file='shared_articleAddDialog'} {/if} diff --git a/com.woltlab.wcf/templates/articleListItems.tpl b/com.woltlab.wcf/templates/articleListItems.tpl index f69585b95a9..4dd80944434 100644 --- a/com.woltlab.wcf/templates/articleListItems.tpl +++ b/com.woltlab.wcf/templates/articleListItems.tpl @@ -1,7 +1,7 @@ {if !$disableAds|isset}{assign var='disableAds' value=false}{/if}
    - {foreach from=$objects item='article' name='articles'} + {foreach from=$view->getItems() item='article' name='articles'} {if $article->getArticleContent()}