1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

[WIP] Resizable columns

Signed-off-by: Pavel Ashevskii <pashevskii@mirantis.com>
This commit is contained in:
Pavel Ashevskii 2020-12-18 17:39:16 +04:00
parent 1e5d682b9b
commit 2922b7ae96
7 changed files with 168 additions and 56 deletions

View File

@ -28,7 +28,8 @@ export interface UserPreferences {
downloadBinariesPath?: string;
kubectlBinariesPath?: string;
openAtLogin?: boolean;
hiddenTableColumns?: Record<string, string[]>
storeTableConfig?: Record<string, Record<string, {width?:number, visible?:boolean, index?: number}>>;
}
export class UserStore extends BaseStore<UserStoreModel> {
@ -55,7 +56,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
downloadMirror: "default",
downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version
openAtLogin: false,
hiddenTableColumns: {},
storeTableConfig: {},
};
protected async handleOnLoad() {
@ -84,15 +85,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}
@action
setHiddenTableColumns(tableId: string, names: Set<string> | string[]) {
this.preferences.hiddenTableColumns[tableId] = Array.from(names);
}
getHiddenTableColumns(tableId: string): Set<string> {
return new Set(this.preferences.hiddenTableColumns[tableId]);
}
@action
resetKubeConfigPath() {
this.kubeConfigPath = kubeConfigDefaultPath;

View File

@ -71,10 +71,11 @@ export class Pods extends React.Component<Props> {
render() {
return (
<KubeObjectListLayout
tableId = "workloads_pods"
isResizable
isConfigurable
className="Pods" store={podsStore}
dependentStores={[volumeClaimStore, eventStore]}
tableId = "workloads_pods"
isConfigurable
sortingCallbacks={{
[columnId.name]: (pod: Pod) => pod.getName(),
[columnId.namespace]: (pod: Pod) => pod.getNs(),

View File

@ -1,6 +1,8 @@
.ItemListLayout {
background: $contentColor;
height: 100%;
min-width: 100%;
width: fit-content;
> .header {
--flex-gap: #{$padding * 2};

View File

@ -20,8 +20,11 @@ import { themeStore } from "../../theme.store";
import { MenuActions } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Checkbox } from "../checkbox";
import { Button } from "../button";
import { userStore } from "../../../common/user-store";
import { namespaceStore } from "../+namespaces/namespace.store";
import logger from "../../../main/logger";
// todo: refactor, split to small re-usable components
@ -58,6 +61,7 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
isSelectable?: boolean; // show checkbox in rows for selecting items
isSearchable?: boolean; // apply search-filter & add search-input
isConfigurable?: boolean;
isResizable?: boolean;
copyClassNameFromHeadCells?: boolean;
sortingCallbacks?: { [sortBy: string]: TableSortCallback };
tableProps?: Partial<TableProps>; // low-level table configuration
@ -83,6 +87,7 @@ const defaultProps: Partial<ItemListLayoutProps> = {
isSearchable: true,
isSelectable: true,
isConfigurable: false,
isResizable: false,
copyClassNameFromHeadCells: true,
preloadStores: true,
dependentStores: [],
@ -99,7 +104,12 @@ interface ItemListLayoutUserSettings {
@observer
export class ItemListLayout extends React.Component<ItemListLayoutProps> {
static defaultProps = defaultProps as object;
private resetTableButtonTimer:any = null;
@observable showResetColumnsWidthsButton = false;
// columnWidthsTmp is for temporary storing column widths at moment between the start and stop of resizing
// it helps to avoid excess writing to the file system
@observable columnWidthsTmp = new Map<string,number>();
@observable userSettings: ItemListLayoutUserSettings = {
showAppliedFilters: false,
};
@ -115,14 +125,14 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
disposeOnUnmount(this, [
reaction(() => toJS(this.userSettings), settings => storage.set(settings)),
]);
if (this.canBeResized || this.canBeConfigured) {
this.initColumnConfig();
}
}
async componentDidMount() {
const { isClusterScoped, isConfigurable, tableId, preloadStores } = this.props;
if (isConfigurable && !tableId) {
throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified");
}
const { isClusterScoped, preloadStores } = this.props;
if (preloadStores) {
this.loadStores();
@ -133,6 +143,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
]);
}
}
}
private loadStores() {
@ -143,6 +154,68 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces));
}
initColumnConfig() {
const {renderTableHeader, tableId } = this.props;
if ( !userStore.preferences?.storeTableConfig?.[tableId] ) {
userStore.preferences.storeTableConfig[tableId] = {};
renderTableHeader.map((cellProps, index) => {
userStore.preferences.storeTableConfig[tableId][cellProps.className] = {index, visible: true};
});
}
}
saveColumnsConfig() {
this.columnWidthsTmp.forEach((value, key) => {
this.getColumnConfig(key).width = value;
});
this.columnWidthsTmp.clear();
}
getColumnConfig(column: string) {
return userStore.preferences.storeTableConfig[this.props.tableId]?.[column];
}
getColumnCalculatedWidth(className: string) : number {
return document.querySelector(`.TableHead > .${className}`).clientWidth;
}
resizeColumn(className: string, width: number){
const { renderTableHeader } = this.props;
this.showResetColumnsWidthsButton = true;
for (let _i = 0; _i < this.getColumnConfig(className).index; _i++) {
if( this.getColumnConfig(renderTableHeader[_i].showWithColumn ?? renderTableHeader[_i].className)?.visible
&& !this.getColumnConfig(renderTableHeader[_i].showWithColumn ?? renderTableHeader[_i].className)?.width) {
const componentWidth = this.getColumnCalculatedWidth(renderTableHeader[_i].className);
this.columnWidthsTmp.set(renderTableHeader[_i].className, componentWidth);
}
}
this.columnWidthsTmp.set(className, width);
//Clear existing timer and set new one
clearTimeout(this.resetTableButtonTimer);
this.resetTableButtonTimer = setTimeout(() => this.showResetColumnsWidthsButton = false, 10000);
}
getStylesOfResizedColumn(className: string) :any {
if (this.columnWidthsTmp.get(className) || this.getColumnConfig(className)?.width) {
return (
{
//"flex-basis": `${this.columnsConfig[className].width}px`,
"min-width": `${this.columnWidthsTmp.get(className)?? this.getColumnConfig(className).width}px`,
"flex-grow": "0",
"flex-shrink": "0"}
);
}
}
private filterCallbacks: { [type: string]: ItemsFilter } = {
[FilterType.SEARCH]: items => {
const { searchFilters, isSearchable } = this.props;
@ -220,6 +293,31 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return this.applyFilters(filterItems, items);
}
get canBeConfigured(): boolean {
return this.props.isConfigurable && this.canBeChanged;
}
get canBeResized(): boolean {
return this.props.isResizable && this.canBeChanged;
}
get canBeChanged(): boolean {
const { tableId, renderTableHeader } = this.props;
if (!tableId) {
return false;
}
if (!renderTableHeader?.every(({ className }) => className)) {
logger.warning("[ItemObjectList]: cannot change an object list without all headers being identifiable");
return false;
}
return true;
}
@autobind()
getRow(uid: string) {
const {
@ -260,7 +358,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
if (!headCell || !this.isHiddenColumn(headCell)) {
return <TableCell key={index} {...cellProps} />;
return <TableCell key={index} {...cellProps} style={this.getStylesOfResizedColumn(cellProps.className)}/>;
}
})
}
@ -362,7 +460,13 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
title: <h5 className="title">{title}</h5>,
info: this.renderInfo(),
filters: <>
{!isClusterScoped && <NamespaceSelectFilter/>}
<Button primary
hidden={!this.showResetColumnsWidthsButton}
onClick={()=>{
this.props.renderTableHeader.map((cellProps) => this.getColumnConfig(cellProps.className).width = null);
this.showResetColumnsWidthsButton = false;
}}>Reset</Button>
{!isClusterScoped && <NamespaceSelectFilter />}
<PageFiltersSelect allowEmpty disableFilters={{
[FilterType.NAMESPACE]: true, // namespace-select used instead
}}/>
@ -392,7 +496,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
renderTableHeader() {
const { renderTableHeader, isSelectable, isConfigurable, store } = this.props;
const { renderTableHeader, isSelectable, isResizable, store } = this.props;
if (!renderTableHeader) {
return;
@ -407,13 +511,13 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
onClick={prevDefault(() => store.toggleSelectionAll(this.items))}
/>
)}
{renderTableHeader.map((cellProps, index) => {
if (!this.isHiddenColumn(cellProps)) {
return <TableCell key={cellProps.id ?? index} {...cellProps} />;
}
})}
<TableCell className="menu">
{isConfigurable && this.renderColumnVisibilityMenu()}
{renderTableHeader.map((cellProps, index) => !this.isHiddenColumn(cellProps) && <TableCell isResizable={isResizable} key={index} {...cellProps}
getCurrentExtent = {() => this.getColumnCalculatedWidth(cellProps.className)}
onResize = {v => this.resizeColumn(cellProps.className,v)}
onResizeStop = {() => this.saveColumnsConfig()}
style={this.getStylesOfResizedColumn(cellProps.className)}/>)}
<TableCell className="menu" >
{this.canBeConfigured && this.renderColumnVisibilityMenu()}
</TableCell>
</TableHead>
);
@ -421,8 +525,8 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
renderList() {
const {
store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem,
tableProps = {},
tableProps = {}, store, hasDetailsView,
addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem
} = this.props;
const { isReady, removeItemsDialog, items } = this;
const { selectedItems } = store;
@ -463,30 +567,16 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
);
}
@computed get hiddenColumns() {
return userStore.getHiddenTableColumns(this.props.tableId);
}
isHiddenColumn({ id: columnId, showWithColumn }: TableCellProps): boolean {
if (!this.props.isConfigurable) {
isHiddenColumn({ className: columnName, showWithColumn }: TableCellProps): boolean {
if (!this.canBeConfigured) {
return false;
}
return this.hiddenColumns.has(columnId) || (
showWithColumn && this.hiddenColumns.has(showWithColumn)
);
return !this.getColumnConfig(showWithColumn ?? columnName)?.visible ?? false;
}
updateColumnVisibility({ id: columnId }: TableCellProps, isVisible: boolean) {
const hiddenColumns = new Set(this.hiddenColumns);
if (!isVisible) {
hiddenColumns.add(columnId);
} else {
hiddenColumns.delete(columnId);
}
userStore.setHiddenTableColumns(this.props.tableId, hiddenColumns);
updateColumnVisibility({ className: columnName }: TableCellProps, isVisible: boolean) {
this.getColumnConfig(columnName).visible = isVisible;
}
renderColumnVisibilityMenu() {
@ -498,8 +588,8 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
!cellProps.showWithColumn && (
<MenuItem key={index} className="input">
<Checkbox
label={cellProps.title ?? `<${cellProps.className}>`}
value={!this.isHiddenColumn(cellProps)}
label = {cellProps.title ?? `<${cellProps.className}>`}
value ={!this.isHiddenColumn(cellProps)}
onChange={isVisible => this.updateColumnVisibility(cellProps, isVisible)}
/>
</MenuItem>

View File

@ -3,6 +3,7 @@
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
&.checkbox {
display: flex;
@ -66,4 +67,4 @@
color: $textColorAccent;
}
}
}
}

View File

@ -1,10 +1,11 @@
import "./table-cell.scss";
import type { TableSortBy, TableSortParams } from "./table";
import React, { ReactNode } from "react";
import React, { ReactNode, CSSProperties } from "react";
import { autobind, cssNames, displayBooleans } from "../../utils";
import { Icon } from "../icon";
import { Checkbox } from "../checkbox";
import { ResizeDirection, ResizeSide, ResizeGrowthDirection, ResizingAnchor } from "../resizing-anchor";
export type TableCellElem = React.ReactElement<TableCellProps>;
@ -16,7 +17,12 @@ export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> {
isChecked?: boolean; // mark checkbox as checked or not
renderBoolean?: boolean; // show "true" or "false" for all of the children elements are "typeof boolean"
sortBy?: TableSortBy; // column name, must be same as key in sortable object <Table sortable={}/>
showWithColumn?: string // id of the column which follow same visibility rules
showWithColumn?: string
style?: CSSProperties;
isResizable?: boolean;
getCurrentExtent?: () => number;
onResize?: (newExtent: number) => void;
onResizeStop?: () => void;
_sorting?: Partial<TableSortParams>; // <Table> sorting state, don't use this prop outside (!)
_sort?(sortBy: TableSortBy): void; // <Table> sort function, don't use this prop outside (!)
_nowrap?: boolean; // indicator, might come from parent <TableHead>, don't use this prop outside (!)
@ -64,8 +70,27 @@ export class TableCell extends React.Component<TableCellProps> {
}
}
renderResizingAnchor() {
const { onResize, onResizeStop, isResizable, getCurrentExtent } = this.props;
if(isResizable) {
return (
<ResizingAnchor
direction={ResizeDirection.HORIZONTAL}
placement={ResizeSide.TRAILING}
growthDirection={ResizeGrowthDirection.LEFT_TO_RIGHT}
getCurrentExtent={getCurrentExtent}
onDrag={onResize}
onEnd={onResizeStop}
minExtent={30}
maxExtent={900}
/>
);
}
}
render() {
const { className, checkbox, isChecked, sortBy, _sort, _sorting, _nowrap, children, title, renderBoolean: displayBoolean, showWithColumn, ...cellProps } = this.props;
const { onResize, onResizeStop, className, isResizable, checkbox, isChecked, sortBy, _sort, _sorting, _nowrap, children, title, renderBoolean: displayBoolean, showWithColumn, ...cellProps } = this.props;
const classNames = cssNames("TableCell", className, {
checkbox,
nowrap: _nowrap,
@ -78,6 +103,7 @@ export class TableCell extends React.Component<TableCellProps> {
{this.renderCheckbox()}
{_nowrap ? <div className="content">{content}</div> : content}
{this.renderSortIcon()}
{this.renderResizingAnchor()}
</div>
);
}

View File

@ -34,4 +34,4 @@
}
}
}
}
}