Files
2024-02-15 17:01:50 +00:00

234 lines
7.0 KiB
TypeScript

import type {Layout} from 'react-grid-layout';
import {compact} from 'react-grid-layout/build/utils';
import pickBy from 'lodash/pickBy';
import sortBy from 'lodash/sortBy';
import zip from 'lodash/zip';
import {defined} from 'sentry/utils';
import {uniqueId} from 'sentry/utils/guid';
import {NUM_DESKTOP_COLS} from './dashboard';
import type {Widget, WidgetLayout} from './types';
import {DisplayType} from './types';
export const DEFAULT_WIDGET_WIDTH = 2;
export const METRIC_WIDGET_MIN_SIZE = {minH: 2, h: 2, w: 2};
const WIDGET_PREFIX = 'grid-item';
// Keys for grid layout values we track in the server
const STORE_KEYS = ['x', 'y', 'w', 'h', 'minW', 'maxW', 'minH', 'maxH'];
export type Position = Pick<Layout, 'x' | 'y'>;
type NextPosition = [position: Position, columnDepths: number[]];
export function generateWidgetId(widget: Widget, index: number) {
return widget.id ? `${widget.id}-index-${index}` : `index-${index}`;
}
export function constructGridItemKey(widget: {id?: string; tempId?: string}) {
return `${WIDGET_PREFIX}-${widget.id ?? widget.tempId}`;
}
export function assignTempId(widget: Widget) {
if (widget.id ?? widget.tempId) {
return widget;
}
return {...widget, tempId: uniqueId()};
}
/**
* Naive positioning for widgets assuming no resizes.
*/
export function getDefaultPosition(index: number, displayType: DisplayType) {
return {
x: (DEFAULT_WIDGET_WIDTH * index) % NUM_DESKTOP_COLS,
y: Number.MAX_SAFE_INTEGER,
w: DEFAULT_WIDGET_WIDTH,
h: displayType === DisplayType.BIG_NUMBER ? 1 : 2,
minH: displayType === DisplayType.BIG_NUMBER ? 1 : 2,
};
}
export function getMobileLayout(desktopLayout: Layout[], widgets: Widget[]) {
if (desktopLayout.length === 0) {
// Initial case where the user has no layout saved, but
// dashboard has widgets
return [];
}
const layoutWidgetPairs = zip(desktopLayout, widgets) as [Layout, Widget][];
// Sort by y and then subsort by x
const sorted = sortBy(layoutWidgetPairs, ['0.y', '0.x']);
const mobileLayout = sorted.map(([layout, widget], index) => ({
...layout,
x: 0,
y: index * 2,
w: 2,
h: widget.displayType === DisplayType.BIG_NUMBER ? 1 : 2,
}));
return mobileLayout;
}
/**
* Reads the layout from an array of widgets.
*/
export function getDashboardLayout(widgets: Widget[]): Layout[] {
type WidgetWithDefinedLayout = Omit<Widget, 'layout'> & {layout: WidgetLayout};
return widgets
.filter((widget): widget is WidgetWithDefinedLayout => defined(widget.layout))
.map(({layout, ...widget}) => ({
...layout,
i: constructGridItemKey(widget),
}));
}
export function pickDefinedStoreKeys(layout: Layout): WidgetLayout {
// TODO(nar): Fix the types here
return pickBy(
layout,
(value, key) => defined(value) && STORE_KEYS.includes(key)
) as WidgetLayout;
}
export function getDefaultWidgetHeight(displayType: DisplayType): number {
return displayType === DisplayType.BIG_NUMBER ? 1 : 2;
}
export function getInitialColumnDepths() {
return Array(NUM_DESKTOP_COLS).fill(0);
}
/**
* Creates an array from layouts where each column stores how deep it is.
*/
export function calculateColumnDepths(
layouts: Pick<Layout, 'h' | 'w' | 'x' | 'y'>[]
): number[] {
const depths = getInitialColumnDepths();
// For each layout's x, record the max depth
layouts.forEach(({x, w, y, h}) => {
// Adjust the column depths for each column the widget takes up
for (let col = x; col < x + w; col++) {
depths[col] = Math.max(y + h, depths[col]);
}
});
return depths;
}
/**
* Find the next place to place a widget and also returns the next
* input when this operation needs to be called multiple times.
*
* @param columnDepths A profile of how deep the widgets in a column extend.
* @param height The desired height of the next widget we want to place.
* @returns An {x, y} positioning for the next available spot, as well as the
* next columnDepths array if this position were used.
*/
export function getNextAvailablePosition(
initialColumnDepths: number[],
height: number
): NextPosition {
const columnDepths = [...initialColumnDepths];
const maxColumnDepth = Math.max(...columnDepths);
// Look for an opening at each depth by scanning from 0, 0
// By scanning from 0 depth to the highest depth, we ensure
// we get the top-most available spot
for (let currDepth = 0; currDepth <= maxColumnDepth; currDepth++) {
for (let start = 0; start <= columnDepths.length - DEFAULT_WIDGET_WIDTH; start++) {
if (columnDepths[start] > currDepth) {
// There are potentially widgets in the way here, so skip
continue;
}
// If all of the columns from start to end (the size of the widget)
// have at most the current depth, then we've found a valid positioning
// No other widgets extend into the space we need
const end = start + DEFAULT_WIDGET_WIDTH;
if (columnDepths.slice(start, end).every(val => val <= currDepth)) {
for (let col = start; col < start + DEFAULT_WIDGET_WIDTH; col++) {
columnDepths[col] = currDepth + height;
}
return [{x: start, y: currDepth}, [...columnDepths]];
}
}
}
for (let col = 0; col < DEFAULT_WIDGET_WIDTH; col++) {
columnDepths[col] = maxColumnDepth;
}
return [{x: 0, y: maxColumnDepth}, [...columnDepths]];
}
export function assignDefaultLayout<T extends Pick<Widget, 'displayType' | 'layout'>>(
widgets: T[],
initialColumnDepths: number[]
): T[] {
let columnDepths = [...initialColumnDepths];
const newWidgets = widgets.map(widget => {
if (defined(widget.layout)) {
return widget;
}
const height = getDefaultWidgetHeight(widget.displayType);
const [nextPosition, nextColumnDepths] = getNextAvailablePosition(
columnDepths,
height
);
columnDepths = nextColumnDepths;
return {
...widget,
layout: {
...nextPosition,
h: height,
minH: height,
w: DEFAULT_WIDGET_WIDTH,
},
};
});
return newWidgets;
}
export function enforceWidgetHeightValues(widget: Widget): Widget {
const {displayType, layout} = widget;
const nextWidget = {
...widget,
};
if (!defined(layout)) {
return nextWidget;
}
const minH = getDefaultWidgetHeight(displayType);
const nextLayout = {
...layout,
h: Math.max(layout?.h ?? minH, minH),
minH,
};
return {...nextWidget, layout: nextLayout};
}
export function generateWidgetsAfterCompaction(widgets: Widget[]) {
// Resolves any potential compactions that need to occur after a
// single widget change would affect other widget positions, e.g. deletion
const nextLayout = compact(getDashboardLayout(widgets), 'vertical', NUM_DESKTOP_COLS);
return widgets.map(widget => {
const layout = nextLayout.find(({i}) => i === constructGridItemKey(widget));
if (!layout) {
return widget;
}
return {...widget, layout};
});
}
export function isValidLayout(layout: Layout) {
return !isNaN(layout.x) && !isNaN(layout.y) && layout.w > 0 && layout;
}