mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
234 lines
7.0 KiB
TypeScript
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;
|
|
}
|