@fleetbase/ember-ui
Version:
Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.
432 lines (363 loc) • 14.3 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
import { later } from '@ember/runloop';
import { filter, alias } from '@ember/object/computed';
export default class TableComponent extends Component {
tableContext;
tableNode;
allRowsToggled = false;
sortColumns = [];
columns;
visibleColumns;
selectedRows;
constructor() {
super(...arguments);
// Initialize sort columns from args if provided
this.initializeSortColumns();
}
get rows() {
const rows = this.args.rows ?? [];
if (isArray(rows)) return rows;
if (typeof rows?.toArray === 'function') return rows.toArray();
return Array.from(rows);
}
get hasRows() {
return isArray(this.rows) && this.rows.length > 0;
}
get allRowsSelected() {
return this.selectedRows.length === (this.rows?.length ?? 0);
}
get emptyStateColspan() {
const selectableColumnCount = this.args.selectable ? 1 : 0;
const expandColumnCount = this.args.canExpand ? 1 : 0;
return (this.visibleColumns?.length ?? 0) + selectableColumnCount + expandColumnCount;
}
get emptyStateContext() {
return {
columns: this.visibleColumns,
rows: this.rows ?? [],
pagination: this.args.pagination,
paginationMeta: this.args.paginationMeta,
searchQuery: this.args.searchQuery,
isFiltered: this.args.isFiltered,
};
}
initializeSortColumns() {
const { sortBy, sortOrder } = this.args;
if (sortBy) {
// Parse comma-delimited sort string if provided
if (typeof sortBy === 'string' && sortBy.includes(',')) {
this.sortColumns = this.parseSortString(sortBy);
} else if (typeof sortBy === 'string') {
// Single sort column
const direction = sortBy.startsWith('-') ? 'desc' : sortOrder || 'asc';
const param = sortBy.startsWith('-') ? sortBy.substring(1) : sortBy;
this.sortColumns = [{ param, direction }];
}
}
}
parseSortString(sortString) {
return sortString.split(',').map((part) => {
const trimmed = part.trim();
if (trimmed.startsWith('-')) {
return { param: trimmed.substring(1), direction: 'desc' };
}
return { param: trimmed, direction: 'asc' };
});
}
buildSortString() {
return this.sortColumns
.map((col) => {
return col.direction === 'desc' ? `-${col.param}` : col.param;
})
.join(',');
}
getSortColumn(param) {
return this.sortColumns.find((col) => col.param === param);
}
getSortPriority(param) {
const index = this.sortColumns.findIndex((col) => col.param === param);
return index >= 0 ? index + 1 : null;
}
setupComponent(tableNode) {
const { onSetup } = this.args;
this.tableNode = tableNode;
this.tableContext.node = tableNode;
this.tableContext.table = this;
// Delay sticky offset calculation to ensure DOM is fully rendered
later(
this,
() => {
this.calculateStickyOffsets();
this.setupScrollListener();
},
50
);
later(
this,
() => {
if (typeof onSetup === 'function') {
onSetup(this, tableNode);
}
},
100
);
}
setupScrollListener() {
// Find the scrollable wrapper
const wrapper = this.tableNode?.closest('.next-table-wrapper');
if (!wrapper) return;
// Add scroll event listener to toggle shadow visibility
wrapper.addEventListener('scroll', () => {
this.updateStickyShadows(wrapper);
});
// Initial check
this.updateStickyShadows(wrapper);
}
updateStickyShadows(wrapper) {
const scrollLeft = wrapper.scrollLeft;
const scrollWidth = wrapper.scrollWidth;
const clientWidth = wrapper.clientWidth;
const maxScrollLeft = scrollWidth - clientWidth;
// Check if scrolled to the start (hide left shadows)
const isAtStart = scrollLeft <= 1;
// Check if scrolled to the end (hide right shadows)
const isAtEnd = scrollLeft >= maxScrollLeft - 1;
// Update left sticky columns
const leftStickyColumns = wrapper.querySelectorAll('.sticky-left');
leftStickyColumns.forEach((cell) => {
if (isAtStart) {
cell.classList.add('at-natural-position');
} else {
cell.classList.remove('at-natural-position');
}
});
// Update right sticky columns
const rightStickyColumns = wrapper.querySelectorAll('.sticky-right');
rightStickyColumns.forEach((cell) => {
if (isAtEnd) {
cell.classList.add('at-natural-position');
} else {
cell.classList.remove('at-natural-position');
}
});
}
calculateStickyOffsets() {
if (!this.tableNode || !this.visibleColumns) {
return;
}
// Calculate left offsets for left-sticky columns
let leftOffset = 0;
// Account for checkbox column if it's sticky
if (this.args.checkboxSticky) {
// Find checkbox column (first th without data-column-id)
const allThs = this.tableNode?.querySelectorAll('thead th');
const checkboxTh = allThs?.[0];
if (checkboxTh && !checkboxTh.hasAttribute('data-column-id')) {
// This is the checkbox column - get its actual width
const width = checkboxTh.offsetWidth || this.args.selectAllColumnWidth || 40;
leftOffset += width;
}
}
const leftStickyColumns = this.visibleColumns.filter((col) => col.sticky === true || col.sticky === 'left');
leftStickyColumns.forEach((column) => {
column._stickyOffset = leftOffset;
column._stickyPosition = 'left';
column._stickyZIndex = 15;
// Get column width from DOM if available
const th = this.tableNode?.querySelector(`th[data-column-id="${column.valuePath}"]`);
if (th) {
leftOffset += th.offsetWidth;
} else {
// Fallback to column width property or default
leftOffset += column.width || 150;
}
});
// Calculate right offsets for right-sticky columns
let rightOffset = 0;
const rightStickyColumns = this.visibleColumns.filter((col) => col.sticky === 'right').reverse();
rightStickyColumns.forEach((column) => {
column._stickyOffset = rightOffset;
column._stickyPosition = 'right';
column._stickyZIndex = 15;
// Get column width from DOM if available
const th = this.tableNode?.querySelector(`th[data-column-id="${column.valuePath}"]`);
if (th) {
const width = th.offsetWidth;
rightOffset += width;
} else {
// Fallback to column width property or default
rightOffset += column.width || 150;
}
});
// Note: visibleColumns is a computed property and doesn't need manual reactivity triggering
// The column objects are mutated directly with _sticky* properties
// Update all sticky cells with the calculated offsets
this.updateStickyCellStyles();
}
updateStickyCellStyles() {
if (!this.tableNode) {
return;
}
// Update header cells
const allThs = this.tableNode.querySelectorAll('thead th');
allThs.forEach((th) => {
const columnId = th.getAttribute('data-column-id');
if (th.classList.contains('is-sticky')) {
// CRITICAL: Always ensure top: 0 for vertical stickiness
th.style.top = '0';
if (!columnId) {
// Checkbox column - always at left: 0
th.style.left = '0px';
} else {
// Find the column object
const column = this.visibleColumns.find((c) => c.valuePath === columnId);
if (column && column._stickyOffset !== undefined) {
const position = column._stickyPosition || 'left';
const offset = column._stickyOffset;
th.style[position] = `${offset}px`;
}
}
}
});
// Update body cells
const allTds = this.tableNode.querySelectorAll('tbody td');
allTds.forEach((td) => {
const columnId = td.getAttribute('data-column-id');
if (td.classList.contains('is-sticky')) {
if (!columnId) {
// Checkbox column - always at left: 0
td.style.left = '0px';
} else {
// Find the column object
const column = this.visibleColumns.find((c) => c.valuePath === columnId);
if (column && column._stickyOffset !== undefined) {
const position = column._stickyPosition || 'left';
const offset = column._stickyOffset;
td.style[position] = `${offset}px`;
}
}
}
});
}
onColumnResize() {
// Recalculate sticky offsets when columns are resized
later(
this,
() => {
this.calculateStickyOffsets();
},
50
);
}
addRow(row) {
if (isArray(row)) {
return this.addRows(row);
}
this.rows.pushObject(row);
return this;
}
addRows(rows = []) {
this.rows.pushObjects(rows);
return this;
}
removeRow(row) {
if (isArray(row)) {
return this.removeRows(row);
}
this.rows.removeObject(row);
return this.resetRowCheckboxes();
}
removeRows(rows = []) {
this.rows.removeObjects(rows);
return this.resetRowCheckboxes();
}
resetRowCheckboxes() {
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows.objectAt(i);
set(row, 'checked', row.checked === true);
}
return this;
}
selectAllRows() {
this.allRowsToggled = !this.allRowsToggled;
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows.objectAt(i);
set(row, 'checked', this.allRowsToggled);
}
}
untoggleAllRows() {
this.untoggleSelectAll();
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows.objectAt(i);
set(row, 'checked', false);
}
}
untoggleSelected() {
this.untoggleSelectAll();
for (let i = 0; i < this.selectedRows.length; i++) {
const row = this.selectedRows.objectAt(i);
set(row, 'checked', false);
}
}
untoggleSelectAll() {
this.allRowsToggled = false;
}
toggleSelectAll() {
this.allRowsToggled = true;
}
getSelectedIds() {
return this.selectedRows.map((_) => _.id);
}
handleSort(column, event) {
if (!column.sortable) {
return;
}
const sortParam = column.sortParam || column.valuePath;
const isMultiSort = event?.shiftKey || false;
// Find existing sort for this column
const existingIndex = this.sortColumns.findIndex((col) => col.param === sortParam);
if (isMultiSort) {
// Multi-column sort mode (Shift+Click)
if (existingIndex >= 0) {
// Column already sorted - toggle direction or remove
const currentSort = this.sortColumns[existingIndex];
if (currentSort.direction === 'asc') {
// Change to descending
this.sortColumns[existingIndex] = { param: sortParam, direction: 'desc' };
} else {
// Remove from sort
this.sortColumns.splice(existingIndex, 1);
}
} else {
// Add new sort column
this.sortColumns.push({ param: sortParam, direction: 'asc' });
}
} else {
// Single column sort mode (Regular Click)
if (existingIndex >= 0 && this.sortColumns.length === 1) {
// Only this column is sorted - toggle direction
const currentSort = this.sortColumns[0];
if (currentSort.direction === 'asc') {
this.sortColumns = [{ param: sortParam, direction: 'desc' }];
} else {
// Remove sort
this.sortColumns = [];
}
} else {
// Replace all sorts with this column
this.sortColumns = [{ param: sortParam, direction: 'asc' }];
}
}
// Trigger reactivity
this.sortColumns = [...this.sortColumns];
// Build sort string and call callback
const sortString = this.buildSortString();
if (typeof this.args.onSort === 'function') {
this.args.onSort(sortString, this.sortColumns);
}
}
}