/* eslint-disable @typescript-eslint/ban-ts-comment */ import { FButton, FDiv, flowElement, FRoot, FSearch, FText } from "@nonfx/flow-core"; import { html, HTMLTemplateResult, nothing, PropertyValueMap, unsafeCSS } from "lit"; import { property, query, state } from "lit/decorators.js"; import { FTable, FTableSelectable, FTableSize, FTableVariant } from "../f-table/f-table"; import { FTcell, FTcellActions, FTcellAlign } from "../f-tcell/f-tcell"; import { FTrow, FTrowChevronPosition, FTrowState } from "../f-trow/f-trow"; import eleStyle from "./f-table-schema.scss?inline"; import globalStyle from "./f-table-schema-global.scss?inline"; import { repeat } from "lit/directives/repeat.js"; import { injectCss } from "@nonfx/flow-core-config"; injectCss("f-table-schema", globalStyle); export type FTableSchemaDataRow = { selected?: boolean; details?: () => HTMLTemplateResult; state?: FTrowState; open?: boolean; id: string; disableSelection?: boolean; expandIconPosition?: FTrowChevronPosition; data: Record; }; export type FTableSchemaData = { header: Record; rows: FTableSchemaDataRow[]; }; export type FTableSchemaCell = { value: T; actions?: FTcellActions; align?: FTcellAlign; template?: (highlightText?: string | null) => HTMLTemplateResult; toString?: () => string; }; export type FTableSchemaHeaderCell = { value: T; template?: () => HTMLTemplateResult; width?: string; align?: FTcellAlign; selected?: boolean; disableSort?: boolean; sticky?: boolean; }; export type FTableSchemaVariant = FTableVariant; export type FTableSchemaSize = FTableSize; export type FTableSchemaSelectable = FTableSelectable; export type FTableSchemaHeaderCellemplate = (value: T) => HTMLTemplateResult; export type FTableSchemaStickyBackground = "default" | "secondary" | "tertiary" | "subtle"; @flowElement("f-table-schema") export class FTableSchema extends FRoot { /** * css loaded from scss file */ static styles = [ unsafeCSS(eleStyle), unsafeCSS(globalStyle), ...FTable.styles, ...FTcell.styles, ...FTrow.styles, ...FButton.styles, ...FSearch.styles, ...FText.styles, ...FDiv.styles ]; /** * @attribute data to display in table */ @property({ type: Object, reflect: true }) data!: FTableSchemaData; @property({ type: String, reflect: true }) variant?: FTableSchemaVariant = "stripped"; /** * header key used to specify sort attribute */ @property({ type: String, reflect: true, attribute: "sort-by" }) sortBy?: string; /** * sort order for `sort-by` attribute */ @property({ type: String, reflect: true, attribute: "sort-order" }) sortOrder?: "asc" | "desc" = "asc"; /** * max rows per page , after that it will paginate on scroll */ @property({ type: Number, reflect: true, attribute: "rows-per-page" }) rowsPerPage?: number = 50; /** * @attribute size to apply on each cell */ @property({ type: String, reflect: true }) size?: FTableSchemaSize = "medium"; /** * @attribute whether to display checkbox or radiobox */ @property({ type: String, reflect: true }) selectable?: FTableSchemaSelectable = "none"; /** * @attribute highlight selected row, when selectable has value "single" or "multiple" */ @property({ type: Boolean, reflect: true, attribute: "highlight-selected" }) highlightSelected = false; // fix for vue set ["highlight-selected"](val: boolean) { this.highlightSelected = val; } /** * @attribute highlight on hover */ @property({ type: Boolean, reflect: true, attribute: "highlight-hover" }) highlightHover = false; // fix for vue set ["highlight-hover"](val: boolean) { this.highlightHover = val; } /** * @attribute highlight on column hover */ @property({ type: Boolean, reflect: true, attribute: "highlight-column-hover" }) highlightColumnHover = true; // fix for vue set ["highlight-column-hover"](val: boolean) { this.highlightColumnHover = val; } /** * @attribute is sticky header */ @property({ type: Boolean, reflect: true, attribute: "sticky-header" }) stickyHeader = false; /** * @attribute is sticky cell background */ @property({ type: String, reflect: true, attribute: "sticky-cell-background" }) stickyCellBackground: FTableSchemaStickyBackground = "default"; /** * filter rows based on search term */ @property({ type: String, reflect: true, attribute: "search-term" }) searchTerm: string | null = null; /** * search on selected header */ @property({ type: String, reflect: true, attribute: "search-scope" }) searchScope = "all"; /** * show search input box on top */ @property({ type: Boolean, reflect: true, attribute: "show-search-bar" }) showSearchBar = true; set ["show-search-bar"](val: boolean) { this.showSearchBar = val; } /** * @attribute header-cell-template */ @property({ reflect: false, type: Function, attribute: "header-cell-template" }) headerCellTemplate?: FTableSchemaHeaderCellemplate; set ["header-cell-template"](val: FTableSchemaHeaderCellemplate | undefined) { this.headerCellTemplate = val; } /** * to show scrollbar */ @property({ type: Boolean, reflect: true, attribute: "show-scrollbar" }) showScrollbar = false; set ["show-scrollbar"](val: boolean) { this.showScrollbar = val; } @state() offset = 0; @query("f-div.load-more") loadMoreButton?: FDiv; @query("#pagination-loader") paginationLoader!: FDiv; @query("#f-table-element") tableElement?: FTable; @query("#f-table-search") tableSearchElement!: FSearch; @query(".f-table-schema-wrapper") fTableWrapper!: HTMLDivElement; nextEmitted = false; searchTimeout?: number; get max() { return this.rowsPerPage ?? 50; } get ariaSortOrder() { return this.sortOrder === "asc" ? "ascending" : "descending"; } get header() { return this.data?.header ? html` ${Object.entries(this.data.header).map((columnHeader, idx) => { let width = undefined; let selected = false; let sticky = undefined; if (typeof columnHeader[1] === "object") { if (columnHeader[1].width) { width = columnHeader[1].width; } selected = columnHeader[1].selected ?? false; sticky = columnHeader[1].sticky; } return html`) => this.handleHeaderInput(event, columnHeader[1])} > ${this.getHeaderCellTemplate(columnHeader[1])} ${columnHeader[1].disableSort ? nothing : this.getSortIcon(columnHeader[0])}`; })} ` : nothing; } get rowsHtml() { return repeat( this.filteredRows, row => row.id, (row, idx) => { const getDetailsSlot = () => { if (row.details) { return html` ${row.details()} `; } else { return nothing; } }; return html` this.handleRowClick(row, e)} @toggle-row=${(e: CustomEvent) => this.toggleRowDetails(row, e)} @selected-row=${(e: CustomEvent) => this.handleRowSelection(row, e)} > ${getDetailsSlot()} ${Object.entries(this.data.header).map((columnHeader, cdx) => { let width = undefined; let selected = false; let sticky = undefined; let actions = undefined; if (typeof columnHeader[1] === "object") { if (columnHeader[1].width) { width = columnHeader[1].width; } selected = columnHeader[1].selected ?? false; sticky = columnHeader[1].sticky; } const cell = row.data[columnHeader[0]]; actions = cell.actions; let highlightTerm = columnHeader[0] === this.searchScope ? this.searchTerm : null; if (this.searchScope === "all") { highlightTerm = this.searchTerm; } return html`${this.getCellTemplate(row.data[columnHeader[0]], highlightTerm)} `; })} `; } ); } get filteredRows() { return this.paginatedRows; } get searchedRows() { if (this.searchScope === "all" && this.searchTerm) { return this.data.rows.filter(row => { return ( Object.values(row.data).findIndex(v => { if (this.searchTerm !== null) { if (v !== null) { if (typeof v.value === "object" && v.toString) { return v .toString() .toLocaleLowerCase() .includes(this.searchTerm.toLocaleLowerCase()); } else { return String(v.value) .toLocaleLowerCase() .includes(this.searchTerm.toLocaleLowerCase()); } } return false; } return true; }) !== -1 ); }); } else if (this.searchScope !== "all" && this.searchTerm) { return this.data.rows.filter(row => { if (this.searchTerm !== null) { const v = row.data[this.searchScope]; if (v !== null) { if (typeof v.value === "object" && v.toString) { return v.toString().toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase()); } else { return String(v.value) .toLocaleLowerCase() .includes(this.searchTerm.toLocaleLowerCase()); } } } return false; }); } return this.data?.rows ?? []; } get sortedRows() { return this.searchedRows.sort((first, second) => { if (this.sortBy) { let columnA = first.data[this.sortBy].value, columnB = second.data[this.sortBy].value; if ( first.data[this.sortBy].toString && typeof columnA === "object" && !(columnA instanceof Date) ) { // @ts-ignore columnA = first.data[this.sortBy].toString(); } if ( second.data[this.sortBy].toString && typeof columnB === "object" && !(columnB instanceof Date) ) { // @ts-ignore columnB = second.data[this.sortBy].toString(); } if (typeof columnA === "string" && typeof columnB === "string") { if (columnA.trim().toLocaleLowerCase() < columnB.trim().toLocaleLowerCase()) { return this.sortOrder === "asc" ? -1 : 1; } if (columnA.trim().toLocaleLowerCase() > columnB.trim().toLocaleLowerCase()) { return this.sortOrder === "asc" ? 1 : -1; } return 0; } else if (typeof columnA === "number" && typeof columnB === "number") { return this.sortOrder === "asc" ? columnA - columnB : columnB - columnA; } else if (columnA instanceof Date && columnB instanceof Date) { return this.sortOrder === "asc" ? (columnA as any) - (columnB as any) : (columnB as any) - (columnA as any); } return 0; } else { return 0; } }); } get paginatedRows() { return this.sortedRows.slice(0, this.offset + this.max); } search(event: CustomEvent) { if (this.tableSearchElement) this.tableSearchElement.loading = true; if (this.searchTimeout) { clearTimeout(this.searchTimeout); } this.searchTimeout = window.setTimeout(() => { this.searchScope = event.detail.scope; this.searchTerm = event.detail.value; }, 300); } get noDataTemplate() { if (this.data.rows.length === 0 && this.data.header) { return html` No data to display `; } return nothing; } render() { this.nextEmitted = false; if (!this.data) { return html` Warning: The 'data' property is required.`; } return html` ${this.showSearchBar ? html` ` : nothing}
${this.header} ${this.rowsHtml} ${this.noDataTemplate}
`; } protected updated(changedProperties: PropertyValueMap | Map): void { super.updated(changedProperties); const handleLoadMoreButton = () => { if ( this.fTableWrapper.scrollHeight === this.fTableWrapper.offsetHeight && this.filteredRows.length < this.searchedRows.length ) { this.loadMoreButton?.style.removeProperty("display"); } else if (this.loadMoreButton) { this.loadMoreButton.style.display = "none"; } }; this.fTableWrapper.onscroll = () => { handleLoadMoreButton(); // offset difference added , instead of exact equal if ( this.fTableWrapper.scrollHeight - (this.fTableWrapper.scrollTop + this.fTableWrapper.offsetHeight) < 24 ) { if (this.filteredRows.length !== this.searchedRows.length) { this.paginationLoader.style.width = this.offsetWidth + "px"; this.paginationLoader.style.display = "flex"; } // settimeout added to display above loader first setTimeout(() => this.paginate()); } if (this.filteredRows.length === this.searchedRows.length && !this.nextEmitted) { this.paginationLoader.style.display = "none"; setTimeout(() => { this.nextEmitted = true; const toggle = new CustomEvent("next", { detail: { offset: this.offset, rowsPerPage: this.rowsPerPage }, bubbles: true, composed: true }); this.dispatchEvent(toggle); }); } }; void this.updateComplete.then(async () => { handleLoadMoreButton(); if (this.tableElement) { await this.tableElement.updateHeaderSelectionCheckboxState(); } if (this.tableSearchElement) this.tableSearchElement.loading = false; }); } handleHeaderInput(event: CustomEvent, headerCell: FTableSchemaHeaderCell) { this.toggleAllRows(event.detail); const toggle = new CustomEvent("header-input", { detail: { value: event.detail, header: headerCell }, bubbles: true, composed: true }); this.dispatchEvent(toggle); } toggleAllRows(val: boolean) { this.data.rows.forEach(row => { row.selected = val; }); } paginate() { if (this.filteredRows.length < this.searchedRows.length) { this.offset += this.max; } } setSortBy(columnKey: string) { if (columnKey === this.sortBy) { if (this.sortOrder === "asc") { this.sortOrder = "desc"; } else { this.sortOrder = "asc"; } } else { this.sortBy = columnKey; this.sortOrder = "asc"; } /** * emitting sort event with latest sortBy and sortOrder value */ const sortEvent = new CustomEvent("sort", { detail: { sortBy: this.sortBy, sortOrder: this.sortOrder }, bubbles: true, composed: true }); this.dispatchEvent(sortEvent); } getSortIcon(columnKey: string) { let iconName = "i-sort"; if (columnKey === this.sortBy) { if (this.sortOrder === "asc") { iconName = "i-sort-asc"; } if (this.sortOrder === "desc") { iconName = "i-sort-desc"; } } return html` { event.stopPropagation(); this.setSortBy(columnKey); }} .icon=${iconName} category="packed" state="neutral" >`; } handleRowSelection(row: FTableSchemaDataRow, event: CustomEvent) { if (this.selectable === "single") { this.data.rows.forEach(row => { row.selected = false; }); } row.selected = event.detail.value; /** * Whenever row is selected/de-selected this event emitts with header object */ const rowInputEvent = new CustomEvent("row-input", { detail: row, bubbles: true, composed: true }); this.dispatchEvent(rowInputEvent); } handleRowClick(row: FTableSchemaDataRow, _event: PointerEvent) { const rowInputEvent = new CustomEvent("row-click", { detail: row, bubbles: true, composed: true }); this.dispatchEvent(rowInputEvent); } toggleRowDetails(row: FTableSchemaDataRow, _event: CustomEvent) { row.open = !row.open; /** * Whenever row is selected/de-selected this event emitts with header object */ const rowInputEvent = new CustomEvent("toggle-row-details", { detail: row, bubbles: true, composed: true }); this.dispatchEvent(rowInputEvent); } getCellTemplate(cell: FTableSchemaCell, highlightTerm: string | null) { if (cell?.template) { return cell.template(highlightTerm); } return html`${cell.value}`; } handleColumnSelection(e: CustomEvent) { if (this.data?.header) { const cellToToggleEntry = Object.entries(this.data.header)[e.detail.columnIndex]; let cellToToggle: string; if (cellToToggleEntry) { cellToToggle = cellToToggleEntry[0]; } Object.entries(this.data.header).forEach(cellEntry => { const [key, cellObject] = cellEntry; if (cellToToggle === key) { cellObject.selected = !cellObject.selected; if (cellObject.selected) { /** * Whenever header is selcted this event emitts with header object */ const headerSelected = new CustomEvent("header-selected", { detail: cellObject, bubbles: true, composed: true }); this.dispatchEvent(headerSelected); } } else { cellObject.selected = false; } }); } } getHeaderCellTemplate(cell: FTableSchemaHeaderCell) { if (cell && typeof cell === "object" && cell.value && cell.template) { return cell.template(); } else if (cell && typeof cell === "object" && cell.value && this.headerCellTemplate) { return this.headerCellTemplate(cell.value); } else if (cell && typeof cell === "object" && cell.value) { return html`${cell.value}`; } return html`${cell}`; } } /** * Required for typescript */ declare global { export interface HTMLElementTagNameMap { "f-table-schema": FTableSchema; } }