diff --git a/shared/common/components/dataGrid/dataGrid.module.scss b/shared/common/components/dataGrid/dataGrid.module.scss new file mode 100644 index 00000000..5a7b29d9 --- /dev/null +++ b/shared/common/components/dataGrid/dataGrid.module.scss @@ -0,0 +1,155 @@ +@import "@edgedb/common/mixins.scss"; + +.scrollbarWrapper { + min-height: 0; + min-width: 0; + flex-grow: 1; +} + +.dataGrid { + width: 100%; + height: 100%; + overflow: auto; + overscroll-behavior: contain; + font-family: "Roboto Mono Variable", monospace; + + @include hideScrollbar; +} + +.innerWrapper { + width: max-content; + height: max-content; + min-width: 100%; + min-height: 100%; +} + +.headers { + position: sticky; + top: 0; + min-width: max-content; + display: grid; + background: var(--panel_background); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); + color: var(--main_text_color); + font-weight: 450; + z-index: 2; + + @include darkTheme { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + } + + .resizeHandle { + position: relative; + justify-self: start; + margin-left: -5px; + width: 9px; + z-index: 1; + cursor: col-resize; + + &:hover, + &.dragging { + background: var(--Grey97); + + &:after { + border-color: var(--Grey85); + } + + @include darkTheme { + background: var(--Grey25); + + &:after { + border-color: var(--Grey40); + } + } + } + + &:after { + content: ""; + position: absolute; + top: 6px; + bottom: 6px; + left: 4px; + border-left: 1px solid var(--panel_border); + } + + &.lastPinned { + &:after { + top: 0; + bottom: 0; + } + } + } +} + +.pinnedHeaders { + position: sticky; + left: 0; + grid-row: 1 / -1; + display: grid; + background: var(--panel_background); + z-index: 2; + border-right: 1px solid var(--panel_border); + + &:after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: -5px; + width: 4px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.06), transparent); + + @include darkTheme { + background: linear-gradient(90deg, rgba(0, 0, 0, 0.15), transparent); + } + } +} + +.gridContent { + position: relative; + color: var(--main_text_color); + margin-top: 1px; +} + +.pinnedContent { + position: sticky; + height: 100%; + left: 0; + background: var(--app_panel_background); + z-index: 1; +} + +.cell { + position: absolute; + height: 40px; + border: 1px solid var(--panel_border); + border-top: 0; + border-left: 0; + box-sizing: border-box; + + &.lastPinned:after { + content: ""; + position: absolute; + top: 0; + bottom: -1px; + right: -5px; + width: 4px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.06), transparent); + } + + @include darkTheme { + border-color: var(--Grey25); + + &.lastPinned:after { + background: linear-gradient(90deg, rgba(0, 0, 0, 0.3), transparent); + } + } +} + +.emptyCell { + background: var(--Grey95) !important; + + @include darkTheme { + background: var(--Grey12) !important; + } +} diff --git a/shared/common/components/dataGrid/index.tsx b/shared/common/components/dataGrid/index.tsx new file mode 100644 index 00000000..b7e5061a --- /dev/null +++ b/shared/common/components/dataGrid/index.tsx @@ -0,0 +1,188 @@ +import {PropsWithChildren, useEffect, useRef, useState} from "react"; +import {observer} from "mobx-react-lite"; + +import cn from "@edgedb/common/utils/classNames"; + +import {useResize} from "../../hooks/useResize"; +import {CustomScrollbars} from "../../ui/customScrollbar"; + +import {DataGridState, DefaultColumnWidth} from "./state"; + +import styles from "./dataGrid.module.scss"; +import {useGlobalDragCursor} from "../../hooks/globalDragCursor"; + +export interface DataGridProps { + state: DataGridState; +} + +export function DataGrid({state, children}: PropsWithChildren) { + const ref = useRef(null); + + useResize(ref, ({width, height}) => + state.setGridContainerSize(width, height) + ); + + useEffect(() => { + const container = state.gridElRef; + + if (container) { + const listener = () => { + state.updateScrollPos(container.scrollTop, container.scrollLeft); + }; + + container.addEventListener("scroll", listener); + + return () => { + container.removeEventListener("scroll", listener); + }; + } + }, [state.gridElRef]); + + return ( + +
{ + state.gridElRef = el; + ref.current = el; + if (el) { + el.scrollTop = state.scrollPos.top; + el.scrollLeft = state.scrollPos.left; + } + }} + className={styles.dataGrid} + > +
{children}
+
+
+ ); +} + +export const GridHeaders = observer(function GridHeaders({ + className, + state, + pinnedHeaders, + headers, +}: { + className?: string; + state: DataGridState; + pinnedHeaders: React.ReactNode; + headers: React.ReactNode; +}) { + const ref = useRef(null); + + useResize(ref, ({height}) => state.setHeaderHeight(height)); + + return ( +
+ {state.pinnedColWidths.length ? ( +
+ {pinnedHeaders} +
+ ) : null} + {headers} +
+ ); +}); + +export function HeaderResizeHandle({ + className, + state, + columnId, + style, +}: { + className?: string; + state: DataGridState; + columnId: string; + style?: React.CSSProperties; +}) { + const [_, setGlobalDrag] = useGlobalDragCursor(); + const [dragging, setDragging] = useState(false); + + return ( +
{ + const startMouseLeft = e.clientX; + const startColWidth = + state._colWidths.get(columnId) ?? DefaultColumnWidth; + const moveListener = (e: MouseEvent) => { + state.setColWidth( + columnId, + e.clientX - startMouseLeft + startColWidth + ); + }; + setGlobalDrag("col-resize"); + setDragging(true); + + window.addEventListener("mousemove", moveListener); + window.addEventListener( + "mouseup", + () => { + window.removeEventListener("mousemove", moveListener); + setGlobalDrag(null); + setDragging(false); + state.onColumnResizeComplete?.(state._colWidths); + }, + {once: true} + ); + }} + /> + ); +} + +export const GridContent = observer(function GridContent({ + className, + style, + state, + pinnedCells, + cells, +}: { + className?: string; + style?: React.CSSProperties; + state: DataGridState; + pinnedCells?: React.ReactNode; + cells: React.ReactNode; +}) { + return ( +
+ {pinnedCells ? ( +
+ {pinnedCells} +
+ ) : null} + {cells} +
+ ); +}); diff --git a/shared/common/components/dataGrid/state.ts b/shared/common/components/dataGrid/state.ts new file mode 100644 index 00000000..fa0ee31c --- /dev/null +++ b/shared/common/components/dataGrid/state.ts @@ -0,0 +1,158 @@ +import {makeObservable, observable, computed, action} from "mobx"; + +export const DefaultColumnWidth = 200; + +export interface GridColumn { + id: string; +} + +interface VisibleRanges { + rows: [number, number]; + cols: [number, number]; +} + +export class DataGridState { + gridElRef: HTMLDivElement | null = null; + + constructor( + public RowHeight: number, + private getColumns: () => GridColumn[], + private getPinnedColumns: () => GridColumn[], + private getRowCount: () => number, + colwidths?: {[columnId: string]: number}, + public onColumnResizeComplete?: (widths: Map) => void + ) { + makeObservable(this); + if (colwidths) { + this._colWidths = new Map(Object.entries(colwidths)); + } + } + + @observable + _colWidths = new Map(); + + @action + setColWidth(columnId: string, width: number) { + this._colWidths.set(columnId, Math.max(width, 60)); + } + + @action + setColWidths(widths: {[columnId: string]: number}) { + for (const [id, width] of Object.entries(widths)) { + this._colWidths.set(id, Math.max(width, 60)); + } + this.onColumnResizeComplete?.(this._colWidths); + } + + @computed + get pinnedColWidths() { + return this.getPinnedColumns().map( + (col) => this._colWidths.get(col.id) ?? DefaultColumnWidth + ); + } + + @computed + get colWidths() { + return this.getColumns().map( + (col) => this._colWidths.get(col.id) ?? DefaultColumnWidth + ); + } + + @computed + get colLefts() { + const widths = this._colWidths; + const cols = [...this.getPinnedColumns(), ...this.getColumns()]; + const lefts: number[] = Array(cols.length + 1); + let left = 0; + for (let i = 0; i < cols.length; i++) { + lefts[i] = left; + left += widths.get(cols[i].id) ?? DefaultColumnWidth; + } + lefts[cols.length] = left; + return lefts; + } + + @computed + get pinnedColsWidth() { + return this.colLefts[this.getPinnedColumns().length]; + } + + @computed + get gridContentWidth() { + return this.colLefts[this.colLefts.length - 1]; + } + + @computed + get gridContentHeight() { + return this.getRowCount() * this.RowHeight; + } + + @observable + headerHeight = 32; + + @action + setHeaderHeight(height: number) { + this.headerHeight = height + 1; + } + + @observable + gridContainerSize = {width: 0, height: 0}; + + @action + setGridContainerSize(width: number, height: number) { + this.gridContainerSize.width = width; + this.gridContainerSize.height = height; + } + + @observable + scrollPos = {top: 0, left: 0}; + + @action + updateScrollPos(scrollTop: number, scrollLeft: number) { + this.scrollPos.top = scrollTop; + this.scrollPos.left = scrollLeft; + } + + @computed({ + equals: (a: VisibleRanges, b: VisibleRanges) => + a.rows[0] === b.rows[0] && + a.rows[1] === b.rows[1] && + a.cols[0] === b.cols[0] && + a.cols[1] === b.cols[1], + }) + get visibleRanges(): VisibleRanges { + const colLefts = this.colLefts; + const scrollPos = this.scrollPos; + const pinnedColCount = this.getPinnedColumns().length; + const pinnedColsWidth = this.pinnedColsWidth; + + let i = pinnedColCount; + for (; i < colLefts.length - 1; i++) { + if (scrollPos.left < colLefts[i] - pinnedColsWidth) break; + } + const startCol = i - 1 - pinnedColCount; + const scrollRight = + scrollPos.left + this.gridContainerSize.width - pinnedColsWidth; + for (; i < colLefts.length - 1; i++) { + if (colLefts[i] - pinnedColsWidth > scrollRight) break; + } + const endCol = i - 1 - pinnedColCount; + + return { + rows: [ + Math.max(0, Math.floor(this.scrollPos.top / this.RowHeight / 10) * 10), + Math.min( + this.getRowCount(), + Math.ceil( + (this.scrollPos.top + + this.gridContainerSize.height - + this.headerHeight) / + this.RowHeight / + 10 + ) * 10 + ), + ], + cols: [startCol, endCol], + }; + } +} diff --git a/shared/common/components/dataGrid/utils.ts b/shared/common/components/dataGrid/utils.ts new file mode 100644 index 00000000..a557dfcc --- /dev/null +++ b/shared/common/components/dataGrid/utils.ts @@ -0,0 +1,57 @@ +export function calculateInitialColWidths( + cols: {id: string; typename: string; isLink: boolean}[], + gridWidth: number +): {[id: string]: number} { + const colWidths: {[id: string]: number} = {}; + const unsizedCols: string[] = []; + let totalWidth = 0; + for (const col of cols) { + if (col.isLink) { + colWidths[col.id] = 280; + continue; + } + const width = sizedColTypes[col.typename]; + if (width != null) { + colWidths[col.id] = width; + totalWidth += width; + } else { + unsizedCols.push(col.id); + } + } + if (unsizedCols.length) { + const width = Math.max( + 200, + Math.min(480, (gridWidth - totalWidth) / unsizedCols.length) + ); + for (const colId of unsizedCols) { + colWidths[colId] = width; + } + } + + return colWidths; +} + +const sizedColTypes: {[typename: string]: number} = { + "std::uuid": 230, + "std::int16": 100, + "std::int32": 130, + "std::int64": 200, + "std::float32": 130, + "std::float64": 200, + "std::decimal": 200, + "std::bigint": 200, + "std::bool": 100, + "std::datetime": 320, + "cal::local_datetime": 280, + "cal::local_date": 130, + "cal::local_time": 170, + "std::cal::local_datetime": 280, + "std::cal::local_date": 130, + "std::cal::local_time": 170, + "std::duration": 200, + "cal::relative_duration": 280, + "cal::date_duration": 100, + "std::cal::relative_duration": 280, + "std::cal::date_duration": 100, + "cfg::memory": 100, +}; diff --git a/shared/common/components/resultGrid/index.tsx b/shared/common/components/resultGrid/index.tsx new file mode 100644 index 00000000..64e37ff1 --- /dev/null +++ b/shared/common/components/resultGrid/index.tsx @@ -0,0 +1,168 @@ +import {observer} from "mobx-react-lite"; + +import cn from "@edgedb/common/utils/classNames"; + +import {GridHeader, ResultGridState, RowHeight} from "./state"; + +import { + DataGrid, + GridContent, + GridHeaders, + HeaderResizeHandle, +} from "../dataGrid"; + +import gridStyles from "../dataGrid/dataGrid.module.scss"; +import styles from "./resultGrid.module.scss"; + +export {createResultGridState, ResultGridState} from "./state"; + +export interface ResultGridProps { + state: ResultGridState; +} + +export function ResultGrid({state}: ResultGridProps) { + return ( + + + + + ); +} + +const ResultGridHeaders = observer(function ResultGridHeaders({ + state, +}: { + state: ResultGridState; +}) { + return ( + [ +
+ {header.name} +
, + ( + header.parent == null + ? header.startIndex != 0 + : header.parent.subHeaders![0] != header + ) ? ( + + ) : null, + ])} + /> + ); +}); + +const ResultGridContent = observer(function ResultGridContent({ + state, +}: ResultGridProps) { + const ranges = state.grid.visibleRanges; + const rowTops = state.rowTops; + + const cells: JSX.Element[] = []; + for (const header of state.flatHeaders.slice( + ranges.cols[0], + ranges.cols[1] + 1 + )) { + let rowIndex = ranges.rows[0]; + while (rowIndex < ranges.rows[1]) { + const {data, indexOffset, endIndex} = state.getData(header, rowIndex); + const tops = rowTops.get(data); + const offsetRowIndex = rowIndex - indexOffset; + let dataIndex = tops + ? tops.findIndex((top) => top > offsetRowIndex) - 1 + : offsetRowIndex; + rowIndex = (tops ? tops[dataIndex] : dataIndex) + indexOffset; + while (dataIndex < data.length && rowIndex < ranges.rows[1]) { + cells.push( + + ); + dataIndex += 1; + rowIndex = (tops ? tops[dataIndex] : dataIndex) + indexOffset; + } + const dataEndIndex = + indexOffset + (tops ? tops[tops.length - 1] : data.length); + if (dataEndIndex !== endIndex && dataEndIndex < ranges.rows[1]) { + cells.push( + + ); + } + rowIndex = endIndex; + } + } + + return ; +}); + +const GridCell = observer(function GridCell({ + state, + header, + rowIndex, + height, + data, +}: { + state: ResultGridState; + header: GridHeader; + rowIndex: number; + height: number; + data: any; +}) { + return ( +
+ {data !== undefined ? ( +
1})} + > + {data?.toString() ?? "{}"} +
+ ) : null} +
+ ); +}); diff --git a/shared/common/components/resultGrid/resultGrid.module.scss b/shared/common/components/resultGrid/resultGrid.module.scss new file mode 100644 index 00000000..e79ac2c7 --- /dev/null +++ b/shared/common/components/resultGrid/resultGrid.module.scss @@ -0,0 +1,20 @@ +@import "@edgedb/common/mixins.scss"; + +.header { + position: relative; + padding: 8px 12px; + overflow: hidden; +} + +.cellContent { + height: 39px; + line-height: 38px; + padding: 0 12px; + top: 64px; + white-space: nowrap; + overflow: hidden; + + &.stickyCell { + position: sticky; + } +} diff --git a/shared/common/components/resultGrid/state.ts b/shared/common/components/resultGrid/state.ts new file mode 100644 index 00000000..e698ebf6 --- /dev/null +++ b/shared/common/components/resultGrid/state.ts @@ -0,0 +1,172 @@ +import {ICodec, ScalarCodec} from "edgedb/dist/codecs/ifaces"; +import {ObjectCodec} from "edgedb/dist/codecs/object"; +import {SetCodec} from "edgedb/dist/codecs/set"; +import {DataGridState} from "../dataGrid/state"; + +export function createResultGridState(codec: ICodec, data: any[]) { + return new ResultGridState(codec, data); +} + +export const RowHeight = 40; + +export interface GridHeader { + id: string; + parent: GridHeader | null; + name: string; + multi: boolean; + codec: ICodec; + depth: number; + startIndex: number; + span: number; + subHeaders: GridHeader[] | null; +} + +export class ResultGridState { + grid: DataGridState; + + _headers: GridHeader[]; + allHeaders: GridHeader[]; + flatHeaders: GridHeader[]; + maxDepth: number; + + rowTops = new Map(); + rowCount: number; + + constructor(codec: ICodec, public data: any[]) { + // makeObservable(this); + + const {headers} = _getHeaders(codec, null); + this._headers = headers; + this.allHeaders = _flattenHeaders(this._headers); + this.flatHeaders = this.allHeaders.filter((h) => h.subHeaders == null); + + this.maxDepth = Math.max(...this.flatHeaders.map((h) => h.depth)); + + this.rowCount = _getRowTops(this.rowTops, data, this._headers); + + this.grid = new DataGridState( + RowHeight, + () => this.flatHeaders, + () => [], + () => this.rowCount + ); + + console.log(this.data); + console.log(this.rowTops); + } + + getData( + header: GridHeader, + rowIndex: number + ): {data: any[]; indexOffset: number; endIndex: number} { + if (!header.parent) { + return { + data: this.data, + indexOffset: 0, + endIndex: this.rowCount, + }; + } + const {data: parentData, indexOffset} = this.getData( + header.parent, + rowIndex + ); + const offsetRowIndex = rowIndex - indexOffset; + const tops = this.rowTops.get(parentData); + const dataIndex = tops + ? tops.findIndex((top) => top > offsetRowIndex) - 1 + : offsetRowIndex; + return { + data: parentData[dataIndex][header.parent.name], + indexOffset: indexOffset + (tops ? tops[dataIndex] : dataIndex), + endIndex: indexOffset + (tops ? tops[dataIndex + 1] : dataIndex + 1), + }; + } +} + +function _getRowTops( + topsMap: Map, + items: any[], + headers: GridHeader[] +): number { + let top = 0; + let dense = true; + const tops: number[] = [0]; + for (const item of items) { + let height = 1; + for (const header of headers) { + if (!header.multi) continue; + const colHeight = header.subHeaders + ? _getRowTops(topsMap, item[header.name], header.subHeaders) + : item[header.name].length; + if (colHeight > height) { + height = colHeight; + } + } + const itemTop = (top += height); + dense = dense && itemTop === tops.length; + tops.push(itemTop); + } + if (!dense) { + topsMap.set(items, tops); + } + return tops[tops.length - 1]; +} + +function _getHeaders( + codec: ICodec, + parent: GridHeader | null, + depth = 0, + indexStart = 0 +): {headers: GridHeader[]; colCount: number} { + if (codec instanceof ObjectCodec) { + const subcodecs = codec.getSubcodecs(); + const headers: GridHeader[] = []; + let colCount = 0; + let i = 0; + for (const field of codec.getFields()) { + let subcodec = subcodecs[i++]; + if (!field.implicit) { + let multi = false; + if (subcodec instanceof SetCodec) { + multi = true; + subcodec = subcodec.getSubcodecs()[0]; + } + const startIndex = indexStart + colCount; + const header: GridHeader = { + id: parent ? `${parent.id}.${field.name}` : field.name, + parent, + name: field.name, + multi, + codec: subcodec, + depth, + startIndex, + span: 1, + subHeaders: null, + }; + headers.push(header); + if (subcodec instanceof ObjectCodec) { + const subheaders = _getHeaders( + subcodec, + header, + depth + 1, + startIndex + ); + header.span = subheaders.colCount; + header.subHeaders = subheaders.headers; + colCount += subheaders.colCount; + } else { + colCount++; + } + } + } + return {headers, colCount}; + } + throw new Error(`unexpected codec kind: ${codec.getKind()}`); +} + +function _flattenHeaders(headers: GridHeader[]): GridHeader[] { + return headers.flatMap((header) => [ + header, + ...(header.subHeaders ? _flattenHeaders(header.subHeaders) : []), + ]); +} diff --git a/shared/common/newui/icons/index.tsx b/shared/common/newui/icons/index.tsx index b88b19ce..8e167533 100644 --- a/shared/common/newui/icons/index.tsx +++ b/shared/common/newui/icons/index.tsx @@ -1,6 +1,7 @@ -export function ChevronDownIcon() { +export function ChevronDownIcon({className}: {className?: string}) { return ( ); } + +export function PinIcon() { + return ( + + + + ); +} + +export function TiltedPinIcon() { + return ( + + + + ); +} + +export function DragHandleIcon() { + return ( + + + + + + + + + ); +} + +export function FilterIcon({className}: {className?: string}) { + return ( + + + + ); +} diff --git a/shared/common/newui/modal/index.tsx b/shared/common/newui/modal/index.tsx index aeda1b6d..1fb12f7a 100644 --- a/shared/common/newui/modal/index.tsx +++ b/shared/common/newui/modal/index.tsx @@ -12,7 +12,7 @@ export interface ModalProps { onClose?: () => void; noCloseOnOverlayClick?: boolean; onSubmit?: () => void; - formError?: string | null; + formError?: string | JSX.Element | null; footerButtons?: JSX.Element; footerDetails?: JSX.Element; footerExtra?: JSX.Element; diff --git a/shared/common/newui/modal/modal.module.scss b/shared/common/newui/modal/modal.module.scss index a0ffee97..154b6085 100644 --- a/shared/common/newui/modal/modal.module.scss +++ b/shared/common/newui/modal/modal.module.scss @@ -34,13 +34,10 @@ } .modal { - box-shadow: - 0px 4px 16px 0px rgba(0, 0, 0, 0.16), + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(0, 0, 0, 0.08); - transition: - transform 0.3s, - opacity 0.3s; + transition: transform 0.3s, opacity 0.3s; :global(.MODAL_TRANSITION) & { transform: translateY(100px); @@ -54,8 +51,7 @@ } .modal { - box-shadow: - 0px 4px 16px 0px rgba(0, 0, 0, 0.4), + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.4), 0px 2px 4px 0px rgba(0, 0, 0, 0.2); } } @@ -81,6 +77,7 @@ flex-direction: column; min-height: 0; width: min-content; + max-width: 100%; border-radius: 12px; border: 1px solid var(--Grey80, #ccc); background: var(--app_panel_background, #f7f7f7); diff --git a/shared/common/newui/theme.module.scss b/shared/common/newui/theme.module.scss index bad2fa67..b12d4f1d 100644 --- a/shared/common/newui/theme.module.scss +++ b/shared/common/newui/theme.module.scss @@ -32,7 +32,7 @@ // Semantic colors --page_background: var(--Grey93); --app_panel_background: var(--Grey97); - --panel_background: #fff; + --panel_background: var(--Grey99); --panel_border: var(--Grey90); --main_text_color: var(--Grey30); diff --git a/shared/common/schemaData/knownTypes.ts b/shared/common/schemaData/knownTypes.ts index 42987c81..192c1363 100644 --- a/shared/common/schemaData/knownTypes.ts +++ b/shared/common/schemaData/knownTypes.ts @@ -13,9 +13,14 @@ export const KnownScalarTypes = [ "cal::local_datetime", "cal::local_date", "cal::local_time", + "std::cal::local_datetime", + "std::cal::local_date", + "std::cal::local_time", "std::duration", "cal::relative_duration", "cal::date_duration", + "std::cal::relative_duration", + "std::cal::date_duration", "std::json", "std::bigint", "cfg::memory", diff --git a/shared/common/styles.scss b/shared/common/styles.scss index 5f12003b..6bbd6620 100644 --- a/shared/common/styles.scss +++ b/shared/common/styles.scss @@ -12,8 +12,8 @@ --accentVioletLightTheme: #cfbffb; --accentVioletDarkTheme: #8979b7; - --accentRedLightTheme: #d78d87; - --accentRedDarkTheme: #af6963; + --accentRedLightTheme: #da7a72; + --accentRedDarkTheme: #8f4741; --grey8: #141414; --grey10: #1a1a1a; diff --git a/shared/inspector/buildScalar.tsx b/shared/inspector/buildScalar.tsx index f69ce213..a7b02696 100644 --- a/shared/inspector/buildScalar.tsx +++ b/shared/inspector/buildScalar.tsx @@ -165,7 +165,7 @@ export function renderValue( : strToString(value); return { body: ( - + {str} {implicitLength && value.length === implicitLength ? "…" : ""} diff --git a/shared/studio/components/dataEditor/dataEditor.module.scss b/shared/studio/components/dataEditor/dataEditor.module.scss index 28c7ca17..ce32b1d2 100644 --- a/shared/studio/components/dataEditor/dataEditor.module.scss +++ b/shared/studio/components/dataEditor/dataEditor.module.scss @@ -6,14 +6,14 @@ height: max-content; min-height: 100%; display: flex; - align-items: center; + min-width: min-content; z-index: 1; filter: drop-shadow(0px 2px 12px rgba(0, 0, 0, 0.25)); &.showBackground { background: #f5f5f5; - border-radius: 4px 0 0 4px; + border-radius: 2px 0 0 2px; @include darkTheme { background: #262626; @@ -30,7 +30,7 @@ .actions { position: absolute; top: 0; - height: 32px; + height: 40px; left: 100%; display: flex; align-items: center; @@ -38,8 +38,8 @@ border-radius: 0 4px 4px 0; .action { - width: 32px; - height: 32px; + width: 40px; + height: 40px; display: flex; align-items: center; justify-content: center; @@ -47,6 +47,7 @@ svg { fill: #fff; + width: 18px; } &:last-child { @@ -157,7 +158,7 @@ border-radius: 4px; padding: 3px 6px; margin: 0; - font-family: Roboto Mono; + font-family: "Roboto Mono Variable", monospace; font-size: 14px; line-height: 22px; min-height: 32px; @@ -165,8 +166,12 @@ white-space: pre; @include hideScrollbar; - .dataEditor & { + .dataEditor > & { resize: vertical; + min-height: 40px; + padding: 7px 10px; + border-radius: 2px; + min-width: 0; } } @@ -210,7 +215,7 @@ .dataEditor > &:not(:last-child) { input, textarea { - border-radius: 4px 0 0 4px; + border-radius: 2px 0 0 2px; } } diff --git a/shared/studio/tabs/dataview/dataInspector.module.scss b/shared/studio/tabs/dataview/dataInspector.module.scss index 0c4e5d88..7d23c469 100644 --- a/shared/studio/tabs/dataview/dataInspector.module.scss +++ b/shared/studio/tabs/dataview/dataInspector.module.scss @@ -52,37 +52,8 @@ @include hideScrollbar; } -.header { - box-sizing: border-box; - position: sticky; - top: 0; - height: 48px; - display: flex; - width: max-content; - min-width: 100%; - padding-left: var(--rowIndexWidth); - padding-right: 4rem; - background: rgba(228, 228, 228, 0.9); - backdrop-filter: blur(4px); - z-index: 2; - - .headerFieldWrapper { - position: relative; - display: flex; - } - - &.hasSubtypeFields { - height: 64px; - padding-top: 16px; - } - - @include darkTheme { - background: rgba(37, 37, 37, 0.9); - - @include breakpoint(mobile) { - background: #333; - } - } +.headers { + grid-template-rows: 1fr auto; } .headerField { @@ -90,10 +61,11 @@ box-sizing: border-box; display: flex; flex-shrink: 0; - padding: 0 8px; + height: 48px; + padding: 0 8px 0 12px; align-items: center; - font-family: Roboto Mono; + font-family: "Roboto Mono Variable"; .fieldTitle { flex-shrink: 1; @@ -107,8 +79,8 @@ } .fieldName { - font-weight: 500; - font-size: 14px; + font-weight: 450; + font-size: 13px; line-height: 16px; color: #333; margin-bottom: 2px; @@ -127,6 +99,7 @@ font-size: 11px; line-height: 16px; color: var(--grey50); + font-weight: 450; } .fieldSort { @@ -191,48 +164,29 @@ } } -.dragHandle { - position: absolute; - right: -6px; - width: 12px; - height: 100%; - cursor: ew-resize; - z-index: 1; - opacity: 0.2; - - background-repeat: no-repeat; - background-position: center; - background-size: 2px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 32'%3E%3Cpath d='M 1 2 V 30' stroke='%23000' stroke-width='1' stroke-linecap='round' /%3E%3C/svg%3E"); - - &:hover { - opacity: 0.3; - } - - @include darkTheme { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 32'%3E%3Cpath d='M 1 2 V 30' stroke='%23fff' stroke-width='1' stroke-linecap='round' /%3E%3C/svg%3E"); - } -} - .subtypeRangeHeader { - position: absolute; - top: -12px; + position: relative; height: 14px; display: flex; align-items: center; justify-content: center; + margin-top: 4px; + margin-bottom: -4px; + z-index: 1; + pointer-events: none; + min-width: 0; .subtypeLabel { - height: 14px; + height: 16px; border-radius: 7px; padding: 0 8px; - background: #a8a8a8; - font-family: Roboto Mono; + background: var(--Grey93); + font-family: "Roboto Mono Variable", monospace; font-style: normal; - font-weight: 400; - font-size: 10px; - line-height: 14px; - color: #fff; + font-weight: 450; + font-size: 11px; + line-height: 15px; + color: var(--secondary_text_color); overflow: hidden; text-overflow: ellipsis; } @@ -240,7 +194,7 @@ &:before { content: ""; position: absolute; - background: #a8a8a8; + background: var(--Grey93); left: 8px; right: 8px; height: 3px; @@ -276,17 +230,29 @@ flex-grow: 1; box-sizing: border-box; overflow: hidden; - line-height: 32px; + line-height: 39px; white-space: nowrap; text-overflow: ellipsis; - padding: 0 8px; - font-family: Roboto Mono; + padding: 0 12px; + font-family: "Roboto Mono Variable", monospace; font-weight: normal; font-size: 14px; &.selectable { user-select: text; } + + .firstCol & { + padding-left: 16px; + } +} + +.hoveredCell { + background: #fff; + + @include darkTheme { + background: var(--Grey18); + } } .loadingCell { @@ -311,15 +277,9 @@ } .linksCell { - height: 32px; + height: 39px; display: flex; align-items: center; - mask-image: linear-gradient( - 90deg, - #000, - #000 calc(100% - 15px), - transparent - ); } .editableCell { @@ -330,14 +290,15 @@ &:hover:after { content: ""; position: absolute; - top: 4px; - left: 0; - right: 0; - bottom: 4px; + inset: -1px; border: 2px solid #047cff; - border-radius: 4px; - opacity: 0.5; + border-radius: 2px; + opacity: 0.8; pointer-events: none; + + .firstCol & { + left: 0; + } } } @@ -346,38 +307,56 @@ &:before { content: ""; position: absolute; - top: 7px; - left: 2px; - bottom: 7px; - border: 2px solid #ffa900; - border-radius: 5px; + top: 0; + left: 0; + bottom: 0; + width: 3px; + background-color: #ffa900; @include darkTheme { - border-color: #e5c18c; + background-color: #b88523; } } } +.hasEdits { + background: #fff5e3; + + @include darkTheme { + background: #4e422c; + } +} + .hasErrors { + background: #fee; + .invalidValue { color: #c44f45; } &:before { - border-color: #d78d87; + background-color: #d17870; } @include darkTheme { + background: #3a2323; + .invalidValue { color: #e6776e; } &:before { - border-color: #af6963; + background-color: #e26e64; } } } .isDeleted { - opacity: 0.5; + opacity: 0.3; + background: rgba(0, 0, 0, 0.07); + pointer-events: none; + + @include darkTheme { + background: rgba(0, 0, 0, 0.45); + } } .undoCellChanges { @@ -395,7 +374,7 @@ fill: #ffa900; @include darkTheme { - fill: #e5c18c; + fill: #b88523; } } } @@ -460,76 +439,35 @@ opacity: 0.8; } -.stickyCol { - position: relative; - height: 0px; - left: 0px; - - .deletedRowStrikethrough { - position: absolute; - left: var(--rowIndexWidth); - width: var(--gridWidth); - height: 40px; - z-index: 1; - - &:after { - content: ""; - position: absolute; - display: block; - left: -2px; - right: -2px; - height: 0px; - border: 1px solid #a1843d; - border-radius: 2px; - top: 20px; - } - } +.inspectorRow { + --lineHeight: 28px; - .rowHoverBg { - position: absolute; - height: 40px; - width: calc(var(--rowIndexWidth) + var(--gridWidth) + 32px); - min-width: 100%; - - &.active { - background: #fafafa; - - @include darkTheme { - background: #262626; - } - } + position: absolute; + font-family: "Roboto Mono Variable", monospace; + font-size: 14px; + display: flex; + align-items: center; + user-select: text; + box-sizing: border-box; + width: 100%; - @include breakpoint(mobile) { - height: 56px; - } - } - - .inspectorRow { - --lineHeight: 28px; - - position: absolute; - font-family: Roboto Mono; - font-size: 14px; - margin-left: var(--rowIndexWidth); - display: flex; - align-items: center; - user-select: text; - width: 100%; - z-index: 1; + &.lastItem { + border-bottom: 1px solid var(--panel_border); } .viewInTableButton { display: flex; align-items: center; height: 20px; - border: 1px solid #666; + border: 1px solid var(--Grey80); border-radius: 11px; - font-family: Inter; + font-family: "Roboto Flex Variable", sans-serif; font-style: normal; font-weight: 500; font-size: 11px; color: #858585; text-transform: uppercase; + white-space: nowrap; padding: 0 8px; margin-left: 1rem; cursor: pointer; @@ -542,6 +480,13 @@ } } +.scalar_str, +.scalar_uuid { + &:before, + &:after { + content: ""; + } +} .scalar_uuid { color: #6f6f6f; @@ -550,45 +495,60 @@ color: #adadad; } } - - &:before, - &:after { - content: ""; - } } .rowIndex { - position: absolute; - width: var(--rowIndexWidth); - height: 40px; - z-index: 1; display: flex; padding-left: 4px; - &.hasLinkEdit:after { - content: ""; - position: absolute; - position: absolute; - top: 2px; - left: 2px; - bottom: 2px; - border: 2px solid #ffa900; - border-radius: 5px; + &.hasLinkEdit, + &.isNewRow, + &.isDeletedRow { + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 3px; + background-color: #ffa900; + + @include darkTheme { + background-color: #b88523; + } + } + } + &.isNewRow:before { + background-color: #58ba2b; @include darkTheme { - border-color: #e5c18c; + background-color: #55873d; } } + &.isDeletedRow:before { + background-color: #d17870; - .cell { + @include darkTheme { + background-color: #e26e64; + } + } + + &.unlinked, + &.isDeletedRow { + background: var(--Grey95); + + @include darkTheme { + background: var(--Grey12); + } + } + + .index { text-align: right; flex-grow: 1; - overflow: visible; - padding-left: 0; - font-size: 12px; - font-weight: 500; - color: #a7a7a7; - line-height: 40px; + font-size: 13px; + font-weight: 450; + color: var(--secondary_text_color); + align-self: center; } @include breakpoint(mobile) { @@ -612,8 +572,7 @@ } .expandRow { - width: 10px; - padding: 0 1rem; + width: 26px; flex-shrink: 0; cursor: pointer; display: flex; @@ -621,13 +580,17 @@ opacity: 0; svg { - width: 100%; + color: var(--tertiary_text_color); transform: rotate(-90deg); transition: transform 0.1s; } - &.isExpanded svg { - transform: none; + &.isExpanded { + opacity: 1; + + svg { + transform: none; + } } &.isHidden { @@ -702,9 +665,7 @@ } &.active { - .cell { - color: #00ba83; - } + background: #fff; .expandRow { opacity: 1; @@ -713,6 +674,10 @@ .deleteRowAction { opacity: 1; } + + @include darkTheme { + background: var(--Grey18); + } } } diff --git a/shared/studio/tabs/dataview/dataInspector.tsx b/shared/studio/tabs/dataview/dataInspector.tsx index 317a9a4f..df68ea3f 100644 --- a/shared/studio/tabs/dataview/dataInspector.tsx +++ b/shared/studio/tabs/dataview/dataInspector.tsx @@ -1,15 +1,5 @@ -import { - createContext, - forwardRef, - Fragment, - useContext, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import {createContext, Fragment, useContext, useEffect} from "react"; import {observer} from "mobx-react"; -import {VariableSizeGrid as Grid} from "react-window"; import {ICodec} from "edgedb/dist/codecs/ifaces"; import {EnumCodec} from "edgedb/dist/codecs/enum"; @@ -18,31 +8,30 @@ import {MultiRangeCodec, RangeCodec} from "edgedb/dist/codecs/range"; import cn from "@edgedb/common/utils/classNames"; -import {useDragHandler, Position} from "@edgedb/common/hooks/useDragHandler"; - import {InspectorRow} from "@edgedb/inspector"; import {renderValue} from "@edgedb/inspector/buildScalar"; import inspectorStyles from "@edgedb/inspector/inspector.module.scss"; -import {useResize} from "@edgedb/common/hooks/useResize"; -import {useInitialValue} from "@edgedb/common/hooks/useInitialValue"; - import styles from "./dataInspector.module.scss"; import { DataInspector as DataInspectorState, + DataRowData, ExpandedRowData, ObjectField, ObjectFieldType, RowKind, } from "./state"; -import {DataEditingManager, UpdateLinkChangeKind} from "./state/edits"; +import { + DataEditingManager, + InsertObjectEdit, + UpdateLinkChangeKind, +} from "./state/edits"; import {useDBRouter} from "../../hooks/dbRoute"; import {SortIcon, SortedDescIcon, TopRightIcon} from "./icons"; import { - ChevronDownIcon, DeleteIcon, UndeleteIcon, UndoChangesIcon, @@ -52,11 +41,23 @@ import { import {PrimitiveType} from "../../components/dataEditor"; import {DataEditor} from "../../components/dataEditor/editor"; import {renderInvalidEditorValue} from "../../components/dataEditor/utils"; -import {CustomScrollbars} from "@edgedb/common/ui/customScrollbar"; import {useIsMobile} from "@edgedb/common/hooks/useMobile"; import {ObjectLikeItem} from "@edgedb/inspector/buildItem"; import {ObjectCodec} from "edgedb/dist/codecs/object"; +import {ChevronDownIcon} from "@edgedb/common/newui"; + +import { + DataGrid, + GridContent, + GridHeaders, + HeaderResizeHandle, +} from "@edgedb/common/components/dataGrid"; +import gridStyles from "@edgedb/common/components/dataGrid/dataGrid.module.scss"; +import {DefaultColumnWidth} from "@edgedb/common/components/dataGrid/state"; +import {calculateInitialColWidths} from "@edgedb/common/components/dataGrid/utils"; +import {FieldConfigButton} from "./fieldConfig"; + const DataInspectorContext = createContext<{ state: DataInspectorState; edits: DataEditingManager; @@ -66,198 +67,405 @@ const useDataInspectorState = () => { return useContext(DataInspectorContext)!; }; -const innerElementType = forwardRef( - ({style, ...props}: any, ref) => { - const {state} = useDataInspectorState(); +interface DataInspectorProps { + state: DataInspectorState; + edits: DataEditingManager; +} + +export default observer(function DataInspectorTable({ + state, + edits, +}: DataInspectorProps) { + useEffect(() => { + if ( + state.grid.gridContainerSize.width > 0 && + state.allFields !== null && + state.grid._colWidths.size <= 1 + ) { + state.grid.setColWidths( + calculateInitialColWidths( + state.selectedFields.map(({id, typename, type}) => ({ + id, + typename, + isLink: type === ObjectFieldType.link, + })), + state.grid.gridContainerSize.width - state.indexColWidth - 40 + ) + ); + } + }, [state.allFields, state.grid.gridContainerSize.width]); - return ( -
+ + + + + + ); +}); + +const DataViewHeaders = observer(function DataViewHeaders({ + state, +}: { + state: DataInspectorState; +}) { + let lastSubtype: {name: string; startIndex: number} | null = null; + const headers: JSX.Element[] = []; + const fields = state.fields ?? []; + for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) { + const field = fields[fieldIndex]; + headers.push( + , + { - state.setHoverRowIndex(null); + /> + ); + if (lastSubtype && lastSubtype.name !== field.subtypeName) { + headers.push( + + ); + } + lastSubtype = + field.subtypeName != null + ? lastSubtype && lastSubtype.name === field.subtypeName + ? lastSubtype + : {name: field.subtypeName, startIndex: fieldIndex} + : null; + } + if (lastSubtype) { + headers.push( + + ); + lastSubtype = null; + } + + const pinnedHeaders: JSX.Element[] = [ + , + ]; + const pinnedFields = state.pinnedFields ?? []; + for (let fieldIndex = 0; fieldIndex < pinnedFields.length; fieldIndex++) { + const field = pinnedFields[fieldIndex]; + const lastField = fieldIndex === pinnedFields.length - 1; + pinnedHeaders.push( + , + + ); + + if (lastSubtype && lastSubtype.name !== field.subtypeName) { + pinnedHeaders.push( + + ); + } + lastSubtype = + field.subtypeName != null + ? lastSubtype && lastSubtype.name === field.subtypeName + ? lastSubtype + : {name: field.subtypeName, startIndex: fieldIndex} + : null; + } + if (lastSubtype) { + pinnedHeaders.push( + ); } -); - -const outerElementType = forwardRef( - ({children, style, ...props}: any, ref) => ( -
-
- - - {children} -
+ + return ( + + ); +}); + +function FieldSubtypeHeader({ + name, + startIndex, + endIndex, +}: { + name: string; + startIndex: number; + endIndex: number; +}) { + return ( +
+
{name}
- ) -); + ); +} -interface DataInspectorProps { - state: DataInspectorState; - edits: DataEditingManager; - className?: string; +interface FieldHeaderProps { + colIndex: number; + field: ObjectField; + isOmitted: boolean; } -export default observer(function DataInspectorTable({ - state, - edits, - className, -}: DataInspectorProps) { - const gridContainer = useRef(null); - const [containerSize, setContainerSize] = useState<[number, number]>([0, 0]); +const FieldHeader = observer(function FieldHeader({ + colIndex, + field, + isOmitted, +}: FieldHeaderProps) { + const {state} = useDataInspectorState(); + + const sortDir = state.sortBy?.fieldId === field.id && state.sortBy.direction; - const gridRef = useRef(null); + return ( +
+ {isOmitted ? ( +
+ +
+ Cannot fetch link data: target of required link is hidden by access + policy +
+
+ ) : null} +
+
+ {field.name} + {field.computedExpr ? := : null} +
+
+ {field.multi ? "multi " : ""} + {field.typename} +
+
- const initialScrollOffset = useInitialValue(() => state.scrollPos); + {!field.secret && + field.type === ObjectFieldType.property && + field.name !== "id" ? ( +
state.setSortBy(field.id)} + > + {sortDir ? : } +
+ ) : null} +
+ ); +}); - const isMobile = useIsMobile(); +const DataViewContent = observer(function DataViewContent({ + state, +}: { + state: DataInspectorState; +}) { + const ranges = state.grid.visibleRanges; - const rowHeight = isMobile ? 58 : 40; + state.updateVisibleOffsets(...ranges.rows); - const fields = isMobile ? state.mobileFieldsAndCodecs.fields : state.fields; + const cells: JSX.Element[] = []; + const pinnedCells: JSX.Element[] = []; - useResize(gridContainer, ({width, height}) => - setContainerSize([width, height]) - ); + const fields = state.fields?.slice(ranges.cols[0], ranges.cols[1] + 1) ?? []; - useLayoutEffect(() => { - const availableWidth = gridContainer.current?.clientWidth; + for (let rowIndex = ranges.rows[0]; rowIndex < ranges.rows[1]; rowIndex++) { + const rowDataIndex = rowIndex - state.insertedRows.length; + const rowData = rowDataIndex >= 0 ? state.getRowData(rowDataIndex) : null; - if (!state.fieldWidthsUpdated && availableWidth && fields) { - const newWidth = Math.min( - Math.floor((availableWidth - 200) / fields.length), - 350 - ); + pinnedCells.push( + + ); - state.setInitialFieldWidths(isMobile ? 180 : newWidth); - gridRef.current?.resetAfterColumnIndex(0); + if (rowData && rowData.kind !== RowKind.data) { + if (rowData.kind === RowKind.expanded && rowData.lastRow) { + cells.push( + + ); + } + continue; } - }, []); - - useEffect(() => { - if (gridRef.current) { - state.gridRef = gridRef.current; - return () => { - state.gridRef = null; - }; + const insertedRow = !rowData ? state.insertedRows[rowIndex] : null; + const data = rowData ? state.getData(rowData.index) : insertedRow!.data; + + let columnIndex = 1; + for (const field of state.pinnedFields) { + pinnedCells.push( + + ); } - }, [gridRef]); - const rowIndexCharWidth = (state.rowCount ?? 0).toString().length; + columnIndex += ranges.cols[0]; + for (const field of fields) { + cells.push( + + ); + } + } return ( - -
sum + width, 0) ?? 0) + "px", - }), - "--gridBottomPadding": - containerSize[1] - - (state.hasSubtypeFields ? 64 : 48) - - 40 + - "px", - } as any - } - > - - { - state.setScrollPos([scrollTop, scrollLeft]); - }} - columnCount={fields?.length ?? 0} - estimatedColumnWidth={180} - columnWidth={(index) => state.fieldWidths![index]} - rowCount={state.gridRowCount} - estimatedRowHeight={rowHeight} - rowHeight={(rowIndex) => { - const rowData = state.getRowData( - rowIndex - state.insertedRows.length - ); - return rowData.kind === RowKind.expanded - ? (rowData.state.state.getItems()[rowData.index + 1]?.height ?? - 1) * 28 - : rowHeight; - }} - overscanRowCount={5} - onItemsRendered={({ - overscanRowStartIndex, - overscanRowStopIndex, - }) => { - state.setVisibleRowIndexes( - overscanRowStartIndex, - overscanRowStopIndex - ); - }} - > - {GridCellWrapper} - - -
-
+ ); }); -const inspectorOverrideStyles = { - uuid: styles.scalar_uuid, -}; - -function GridCellWrapper({ +const GridCellWrapper = observer(function GridCellWrapper({ + state, + field, columnIndex, rowIndex, - style, + data, + insertedRow, }: { + state: DataInspectorState; + field: ObjectField; columnIndex: number; rowIndex: number; - style: any; + data: any; + insertedRow: InsertObjectEdit | null; }) { - const {state} = useDataInspectorState(); + const isEmptyCell = + field.subtypeName && + data?.__tname__ && + data?.__tname__ !== field.subtypeName; return (
state.setHoverRowIndex(rowIndex)} + onMouseLeave={() => state.setHoverRowIndex(null)} > - + {!isEmptyCell ? ( + + ) : null}
); -} +}); -function renderCellValue(value: any, codec: ICodec): JSX.Element { +const ExpandedEndCell = observer(function ExpandedEndCell({ + state, + rowIndex, +}: { + state: DataInspectorState; + rowIndex: number; +}) { + return ( +
+ ); +}); + +const inspectorOverrideStyles = { + uuid: styles.scalar_uuid, + str: styles.scalar_str, +}; + +function renderCellValue( + value: any, + codec: ICodec, + nested = false +): JSX.Element { switch (codec.getKind()) { case "scalar": case "range": @@ -270,7 +478,7 @@ function renderCellValue(value: any, codec: ICodec): JSX.Element { ? codec.getSubcodecs()[0].getKnownTypeName() : undefined, false, - inspectorOverrideStyles, + !nested ? inspectorOverrideStyles : undefined, 100 ).body; case "set": @@ -280,7 +488,7 @@ function renderCellValue(value: any, codec: ICodec): JSX.Element { {(value as any[]).map((item, i) => ( {i !== 0 ? ", " : null} - {renderCellValue(item, codec.getSubcodecs()[0])} + {renderCellValue(item, codec.getSubcodecs()[0], true)} ))} {"}"} @@ -293,7 +501,7 @@ function renderCellValue(value: any, codec: ICodec): JSX.Element { {(value as any[]).map((item, i) => ( {i !== 0 ? ", " : null} - {renderCellValue(item, codec.getSubcodecs()[0])} + {renderCellValue(item, codec.getSubcodecs()[0], true)} ))} ] @@ -306,7 +514,7 @@ function renderCellValue(value: any, codec: ICodec): JSX.Element { {(value as any[]).map((item, i) => ( {i !== 0 ? ", " : null} - {renderCellValue(item, codec.getSubcodecs()[i])} + {renderCellValue(item, codec.getSubcodecs()[i], true)} ))} ) @@ -323,7 +531,7 @@ function renderCellValue(value: any, codec: ICodec): JSX.Element { {i !== 0 ? ", " : null} {name} {" := "} - {renderCellValue(value[name], subCodecs[i])} + {renderCellValue(value[name], subCodecs[i], true)} ))} ) @@ -336,31 +544,16 @@ function renderCellValue(value: any, codec: ICodec): JSX.Element { } const GridCell = observer(function GridCell({ - columnIndex, - rowIndex, + field, + data, + insertedRow, }: { - columnIndex: number; - rowIndex: number; + field: ObjectField; + data: any; + insertedRow: InsertObjectEdit | null; }) { const {state, edits} = useDataInspectorState(); const {navigate, currentPath} = useDBRouter(); - const basePath = currentPath.join("/"); - - const rowDataIndex = rowIndex - state.insertedRows.length; - - const isMobile = useIsMobile(); - - const rowData = rowDataIndex >= 0 ? state.getRowData(rowDataIndex) : null; - - if (rowData && rowData.kind !== RowKind.data) { - return null; - } - - const fields = isMobile ? state.mobileFieldsAndCodecs.fields : state.fields; - const field = fields![columnIndex]; - const insertedRow = !rowData ? state.insertedRows[rowIndex] : null; - - const data = rowData ? state.getData(rowData.index) : insertedRow!.data; const isDeletedRow = edits.deleteEdits.has(data?.id); @@ -376,18 +569,12 @@ const GridCell = observer(function GridCell({ const _value = cellEditState?.value !== undefined ? cellEditState.value.value - : data?.[rowData ? field.queryName : field.name] ?? null; + : data?.[insertedRow ? field.name : field.queryName] ?? null; const value = insertedRow && _value ? _value.value : _value; - const isEmptySubtype = - field.subtypeName && - data?.__tname__ && - data?.__tname__ !== field.subtypeName; - if ( !isDeletedRow && - !isEmptySubtype && field.type === ObjectFieldType.property && edits.activePropertyEdit?.cellId === cellId ) { @@ -400,9 +587,7 @@ const GridCell = observer(function GridCell({ let content: JSX.Element | null = null; let selectable = false; - if (isEmptySubtype) { - content = -; - } else if (data) { + if (data) { if (field.secret) { content = secret data hidden; } else if (field.type === ObjectFieldType.property) { @@ -416,19 +601,18 @@ const GridCell = observer(function GridCell({
) : null; - if ((!rowData && field.name === "id") || value === null) { + if ((insertedRow && field.name === "id") || value === null) { content = ( <> - {(!rowData ? field.default ?? field.computedExpr : null) ?? "{}"} + {(insertedRow ? field.default ?? field.computedExpr : null) ?? + "{}"} {undoEdit} ); } else { - const codec = isMobile - ? state.mobileFieldsAndCodecs.codecs?.[columnIndex] - : state.dataCodecs?.[columnIndex]; + const codec = state.dataCodecs?.get(field.queryName); if (codec) { content = ( @@ -478,7 +662,7 @@ const GridCell = observer(function GridCell({ if (Object.keys(counts).length === 0) { content = ( - {field.required && !insertedRow + {field.required && !insertedRow && !linkEditState ? "hidden by access policy" : "{}"} @@ -500,11 +684,9 @@ const GridCell = observer(function GridCell({ } const isEditable = - !isMobile && - !isEmptySubtype && !field.computedExpr && !state.objectType?.readonly && - (!field.readonly || !rowData) && + (!field.readonly || insertedRow) && data && (field.type === ObjectFieldType.link || field.name !== "id"); @@ -516,7 +698,8 @@ const GridCell = observer(function GridCell({ [styles.isDeleted]: isDeletedRow || editedLinkChange?.kind === UpdateLinkChangeKind.Remove || - (!state.parentObject?.isMultiLink && + (!insertedRow && + !state.parentObject?.isMultiLink && !!editedLink && (state.parentObject?.editMode ? !!data?.__isLinked @@ -527,12 +710,14 @@ const GridCell = observer(function GridCell({ field.type === ObjectFieldType.property, [styles.linkCell]: field.type === ObjectFieldType.link, [styles.hasEdits]: - !isDeletedRow && rowData && (!!cellEditState || !!linkEditState), + !isDeletedRow && + !insertedRow && + (!!cellEditState || !!linkEditState), [styles.hasErrors]: isEditable && ((cellEditState && !cellEditState.value.valid) || (insertedRow && _value && !_value.valid) || - (!rowData && + (insertedRow && isEditable && field.required && !field.hasDefault && @@ -542,7 +727,7 @@ const GridCell = observer(function GridCell({ onClick={() => { if (field.type === ObjectFieldType.link && content !== null) { state.openNestedView( - basePath, + currentPath.join("/"), navigate, data.id, data.__tname__, @@ -559,7 +744,7 @@ const GridCell = observer(function GridCell({ ); } else { state.openNestedView( - basePath, + currentPath.join("/"), navigate, data.id, data.__tname__, @@ -575,158 +760,29 @@ const GridCell = observer(function GridCell({ ); }); -const FieldHeaders = observer(function FieldHeaders() { - const {state} = useDataInspectorState(); - const isMobile = useIsMobile(); - - const fields = isMobile ? state.mobileFieldsAndCodecs.fields : state.fields; - - return ( -
-
- {[...state.subtypeFieldRanges?.entries()].map( - ([subtypeName, {left, width}]) => ( -
-
{subtypeName}
-
- ) - )} - {fields?.map((field, i) => ( - - ))} -
-
- ); -}); - -interface FieldHeaderProps { - colIndex: number; - field: ObjectField; - isOmitted: boolean; -} - -const FieldHeader = observer(function FieldHeader({ - colIndex, - field, - isOmitted, -}: FieldHeaderProps) { - const {state} = useDataInspectorState(); - const fieldWidth = state.fieldWidths[colIndex]; - - const resizeHandler = useDragHandler(() => { - let initialWidth: number; - let initialPos: Position; - - return { - onStart(initialMousePos: Position) { - initialPos = initialMousePos; - initialWidth = state.fieldWidths[colIndex]; - }, - onMove(currentMousePos: Position) { - const xDelta = currentMousePos.x - initialPos.x; - state.setFieldWidth(colIndex, initialWidth + xDelta); - state.gridRef?.resetAfterColumnIndex(colIndex); - }, - }; - }, []); - - const sortDir = - state.sortBy?.fieldIndex === colIndex && state.sortBy.direction; - - return ( -
- {isOmitted ? ( -
- -
- Cannot fetch link data: target of required link is hidden by access - policy -
-
- ) : null} -
-
- {field.name} - {field.computedExpr ? := : null} -
-
- {field.multi ? "multi " : ""} - {field.typename} -
-
- - {!field.secret && - field.type === ObjectFieldType.property && - field.name !== "id" ? ( -
state.setSortBy(colIndex)} - > - {sortDir ? : } -
- ) : null} - -
-
- ); -}); - -const StickyCol = observer(function StickyCol() { - const {state} = useDataInspectorState(); - const [startIndex, endIndex] = state.visibleIndexes; - - return ( -
- {Array(Math.min(endIndex - startIndex + 1)) - .fill(0) - .map((_, i) => ( - - ))} -
- ); -}); - interface StickyRowProps { + state: DataInspectorState; rowIndex: number; + rowData: DataRowData | ExpandedRowData | null; } -const StickyRow = observer(function StickyRow({rowIndex}: StickyRowProps) { - const {state} = useDataInspectorState(); - - const style = (state.gridRef as any)?._getItemStyle(rowIndex, 0) ?? {}; - - const rowDataIndex = rowIndex - state.insertedRows.length; - const rowData = rowDataIndex >= 0 ? state.getRowData(rowDataIndex) : null; - - const isMobile = useIsMobile(); +const StickyRow = observer(function StickyRow({ + state, + rowIndex, + rowData, +}: StickyRowProps) { + // const isMobile = useIsMobile(); if (rowData?.kind === RowKind.expanded) { - return isMobile ? ( - - ) : ( - - ); + // return isMobile ? ( + // + // ) : ( + return ; + // ); } else { return ( ); @@ -736,17 +792,15 @@ const StickyRow = observer(function StickyRow({rowIndex}: StickyRowProps) { const DataRowIndex = observer(function DataRowIndex({ rowIndex, dataIndex, - styleTop, active, }: { rowIndex: number; dataIndex: number | null; - styleTop: any; active: boolean; }) { const {state, edits} = useDataInspectorState(); - const isMobile = useIsMobile(); + // const isMobile = useIsMobile(); if (dataIndex !== null && dataIndex >= (state.rowCount ?? 0)) return null; @@ -894,194 +948,218 @@ const DataRowIndex = observer(function DataRowIndex({ return ( <>
state.setHoverRowIndex(rowIndex)} + onMouseLeave={() => state.setHoverRowIndex(null)} >
{rowAction}
-
+
{dataIndex !== null ? dataIndex + 1 : null}
{dataIndex !== null ? ( - isMobile ? ( - - ) : ( -
{ - state.toggleRowExpanded(dataIndex); - state.gridRef?.resetAfterRowIndex(rowIndex); - }} - > - -
- ) - ) : null} + // isMobile ? ( + // + // ) : ( +
{ + state.toggleRowExpanded(dataIndex); + }} + > + +
+ ) : // ) + null}
-
state.setHoverRowIndex(rowIndex)} - /> - {isDeletedRow ? ( -
- ) : null} ); }); const ExpandedDataInspector = observer(function ExpandedDataInspector({ + rowIndex, rowData, - styleTop, }: { + rowIndex: number; rowData: ExpandedRowData; - styleTop: any; }) { const {state} = useDataInspectorState(); const {navigate, currentPath} = useDBRouter(); const basePath = currentPath.join("/"); - const item = rowData.state.getItems()?.[rowData.index]; return ( -
- {item ? ( - <> - { - rowData.state.toggleExpanded(rowData.index); + <> + {rowData.indexes.map((index) => { + const item = rowData.state.getItems()?.[index]; + const lastItem = index === rowData.state.itemsLength - 1; + return ( +
- {item.level === 2 && - rowData.state.linkFields.has(item.fieldName as string) ? ( -
- state.openNestedView( - basePath, - navigate, - rowData.state.objectId, - rowData.state.objectTypeName, - state.fields!.find((field) => field.name === item.fieldName)! - ) - } - > - View in Table -
- ) : null} - - ) : ( -
Loading...
- )} -
- ); -}); - -interface MobileDataInspectorProps { - rowData: ExpandedRowData; -} - -export const MobileDataInspector = ({rowData}: MobileDataInspectorProps) => { - const item = rowData.state.getItems()?.[0] as ObjectLikeItem | undefined; - - const {state} = useDataInspectorState(); - const fields = - state.allFields?.fields.filter( - (field) => - !field.subtypeName || - field.subtypeName === rowData.state.objectTypeName - ) || []; - const codecs = - (item?.codec as ObjectCodec)?.getFields().reduce((codecs, {name}, i) => { - codecs[name] = item?.codec.getSubcodecs()[i]!; - return codecs; - }, {} as {[name: string]: ICodec}) ?? {}; - - const {navigate, currentPath} = useDBRouter(); - const basePath = currentPath.join("/"); - - const closeExtendedView = () => { - state.toggleRowExpanded(rowData.dataRowIndex); - state.gridRef?.resetAfterRowIndex(rowData.dataRowIndex); - }; - - return ( -
-
- {item && - fields.map((field) => { - const isLink = field.type === ObjectFieldType.link; - const data = item.data; - const value = isLink - ? Number(data[`__count_${field.name}`]) - : data[field.name]; - - const codec = codecs[field.name]; - - return ( -
-
- {field.name} - - {`${field.multi ? "multi" : ""} ${field.typename}`} - -
- {isLink ? ( - - ) : codec ? ( - renderCellValue(value, codec) - ) : ( -

{value}

- )} -
- ); - })} -
-
- - -

- {item ? item.data.__tname__ : `loading...`} -

-
-
+ View in Table +
+ ) : null} + + ) : ( +
Loading...
+ )} +
+ ); + })} + ); -}; +}); + +// interface MobileDataInspectorProps { +// rowData: ExpandedRowData; +// } + +// export const MobileDataInspector = ({rowData}: MobileDataInspectorProps) => { +// const item = rowData.state.getItems()?.[0] as ObjectLikeItem | undefined; + +// const {state} = useDataInspectorState(); +// const fields = +// state.allFields?.fields.filter( +// (field) => +// !field.subtypeName || +// field.subtypeName === rowData.state.objectTypeName +// ) || []; +// const codecs = +// (item?.codec as ObjectCodec)?.getFields().reduce((codecs, {name}, i) => { +// codecs[name] = item?.codec.getSubcodecs()[i]!; +// return codecs; +// }, {} as {[name: string]: ICodec}) ?? {}; + +// const {navigate, currentPath} = useDBRouter(); +// const basePath = currentPath.join("/"); + +// const closeExtendedView = () => { +// state.toggleRowExpanded(rowData.dataRowIndex); +// state.gridRef?.resetAfterRowIndex(rowData.dataRowIndex); +// }; + +// return ( +//
+//
+// {item && +// fields.map((field) => { +// const isLink = field.type === ObjectFieldType.link; +// const data = item.data; +// const value = isLink +// ? Number(data[`__count_${field.name}`]) +// : data[field.name]; + +// const codec = codecs[field.name]; + +// return ( +//
+//
+// {field.name} +// +// {`${field.multi ? "multi" : ""} ${field.typename}`} +// +//
+// {isLink ? ( +// +// ) : codec ? ( +// renderCellValue(value, codec) +// ) : ( +//

{value}

+// )} +//
+// ); +// })} +//
+//
+// + +//

+// {item ? item.data.__tname__ : `loading...`} +//

+//
+//
+// ); +// }; diff --git a/shared/studio/tabs/dataview/dataview.module.scss b/shared/studio/tabs/dataview/dataview.module.scss index e2aac667..c870462b 100644 --- a/shared/studio/tabs/dataview/dataview.module.scss +++ b/shared/studio/tabs/dataview/dataview.module.scss @@ -13,13 +13,19 @@ flex-direction: column; height: 100%; min-height: 0; - background: var(--app-panel-bg); - border-radius: 8px; + background: var(--app_panel_background); + border-radius: 12px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.04), 0 0 4px rgba(0, 0, 0, 0.06); overflow: hidden; @include breakpoint(mobile) { border-radius: 0; } + + @include darkTheme { + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.2), + 0px 0px 4px 0px rgba(0, 0, 0, 0.3); + } } .loadingSkeleton { @@ -35,12 +41,12 @@ &:before { content: ""; position: absolute; - left: 8px; - right: 8px; + left: 12px; + right: 12px; top: 0; bottom: 0; - background: #ededed; - border-radius: 6px 6px 0 0; + background: linear-gradient(var(--Grey97) 60%, var(--Grey93)); + border-radius: 12px 12px 0 0; @include darkTheme { background: #242424; @@ -57,30 +63,46 @@ align-items: center; flex-shrink: 0; height: 40px; - background: #d9d9d9; - padding-left: 2rem; + background: var(--panel_background); + padding-left: 4px; + border-bottom: 1px solid var(--panel_border); .nestedView & { padding-left: 0; } .objectSelect { - font-family: Roboto Mono; + font-family: "Roboto Mono Variable", monospace; font-size: 13px; + font-weight: 450; + height: 32px; + padding: 0 12px; + border-radius: 7px; + color: var(--main_text_color); + --hoverBg: var(--Grey93); .modName { - opacity: 0.65; + opacity: 0.7; + } + + @include darkTheme { + --hoverBg: var(--Grey30); + // color: red; } } - @include darkTheme { - background: #1f1f1f; + button { + font-family: "Roboto Flex Variable", sans-serif; + height: 32px; + padding: 0 8px; } .insertSelect { + height: 34px; + box-shadow: none; margin-right: 16px; font-weight: 500; - color: var(--btn-text-color); + box-sizing: border-box; @include breakpoint(mobile) { display: none; @@ -89,15 +111,42 @@ .rowCount { margin: 0 auto; - font-family: "Roboto Mono"; - font-size: 14px; + display: flex; + gap: 8px; + align-items: center; + font-family: "Roboto Mono Variable", monospace; + font-size: 13px; - span { + > span { opacity: 0.6; font-style: italic; } } + .refreshDataButton { + height: 32px; + width: 32px; + border: none; + color: var(--secondary_text_color); + padding: 0; + opacity: 0.7; + + span { + display: contents; + } + + &:hover { + opacity: 1; + background-color: var(--Grey93); + } + + @include darkTheme { + &:hover { + background-color: var(--Grey30); + } + } + } + @include breakpoint(mobile) { position: absolute; bottom: 0; @@ -225,7 +274,7 @@ .upButton { height: 100%; width: 40px; - background: #ccc; + background: var(--Grey80); display: flex; align-items: center; justify-content: center; @@ -240,7 +289,7 @@ } @include darkTheme { - background: #333333; + background: var(--Grey30); svg { fill: #adadad; @@ -271,12 +320,11 @@ } .nestedPathStep { - font-family: Roboto Mono; - font-style: normal; - font-weight: normal; + font-family: "Roboto Mono Variable", monospace; + font-weight: 450; font-size: 13px; line-height: 16px; - color: #4c4c4c; + color: var(--main_text_color); margin-right: 2rem; @include breakpoint(mobile) { @@ -341,60 +389,44 @@ align-self: stretch; display: flex; align-items: center; -} - -.headerButton { - margin: 4px; - display: flex; - align-items: center; - align-self: center; - cursor: pointer; - background: #eaeaea; - height: 28px; - border-radius: 14px; - padding: 0 12px; - font-weight: 500; - color: var(--btn-text-color); - - &:hover { - background: #cecece; - } + gap: 12px; + padding-right: 4px; - @include darkTheme { - background: #141414; + .reviewChanges { + --buttonPrimaryBackground: #e79e10; - &:hover { - background: #2a2a2a; + @include darkTheme { + --buttonPrimaryBackground: #b88523; } } - &.reviewChanges { - background: #ffa900; - color: #fff; + .filterButton { + padding: 0 4px 0 8px; + border: 0; + border-radius: 7px; + &.filterOpen, &:hover { - background: #e69900; - } + background: var(--Grey93); - @include darkTheme { - background: #e5c18c; - color: #141414; - - &:hover { - background: #cbaa7a; + @include darkTheme { + background: var(--Grey30); } } - } -} + &.filterOpen .arrowIcon { + transform: rotate(180deg); + } -.headerSelect { - background: #eaeaea; - height: 28px; - border-radius: 14px; - padding: 0 8px; + &.filterActive { + span, + .filterIcon { + color: #a15ec0; - @include darkTheme { - background: #141414; + @include darkTheme { + color: #d48cf5; + } + } + } } } @@ -515,15 +547,13 @@ } .filterPanel { - --code-editor-bg: #f2f2f2; + --code-editor-bg: var(--app_panel_background); position: relative; display: flex; background: var(--code-editor-bg); height: 190px; - - @include darkTheme { - --code-editor-bg: #2e2e2e; - } + border-bottom: 1px solid var(--panel_border); + flex-shrink: 0; .title { display: none; @@ -612,29 +642,21 @@ position: absolute; bottom: 0px; right: 0px; - margin: 6px; - margin-right: 16px; + margin: 8px; display: flex; align-items: center; - --buttonTextColour: #fff; + gap: 8px; - .clearFilterButton { - --buttonBg: var(--accentRed); - } - .disableFilterButton { - --buttonBg: #e5c18c; + button { + height: 32px; + padding: 0 8px; } .applyFilterButton { - --buttonBg: var(--accentGreen); + --buttonPrimaryBackground: var(--accentGreen); } - - @include darkTheme { - --buttonTextColour: #1f1f1f; - - .disableFilterButton { - --buttonBg: #a1843d; - } + .clearFilterButton { + --buttonPrimaryBackground: var(--accentRed); } @include breakpoint(mobile) { diff --git a/shared/studio/tabs/dataview/editsModal.module.scss b/shared/studio/tabs/dataview/editsModal.module.scss index ff78b295..54babcfb 100644 --- a/shared/studio/tabs/dataview/editsModal.module.scss +++ b/shared/studio/tabs/dataview/editsModal.module.scss @@ -6,25 +6,24 @@ color: var(--app-text-colour); } +.modalContent { + width: 700px; +} + .codeBlock { - background: var(--app-panel-bg); - padding: 16px; - border-radius: 8px; - overflow: auto; + font-size: 14px; + line-height: 22px; + width: max-content; pre { - font-family: Roboto Mono, monospace; + font-family: "Roboto Mono Variable", monospace; margin: 0; margin-left: 1em; } - - @include darkTheme { - background: var(--app-bg); - } } .statementName { - font-family: Roboto Mono; + font-family: "Roboto Mono Variable", monospace; font-size: 13px; opacity: 0.65; } @@ -33,35 +32,35 @@ &:not(:last-child) { margin-bottom: 2em; } -} -.errorMessage { - background: rgba(222, 83, 83, 0.1); - color: #de5353; - padding: 7px 8px; - border-radius: 4px; - margin-top: 6px; - display: flex; - align-items: center; - line-height: 20px; - font-size: 14px; - - svg { - flex-shrink: 0; - fill: currentColor; - height: 15px; - margin-right: 6px; - } + .errorMessage { + background: rgba(222, 83, 83, 0.1); + color: #de5353; + padding: 7px 8px; + border-radius: 4px; + margin-top: 6px; + display: flex; + align-items: center; + line-height: 20px; + font-size: 14px; + + svg { + flex-shrink: 0; + fill: currentColor; + height: 15px; + margin-right: 6px; + } - .errorName { - font-weight: 500; - } + .errorName { + font-weight: 500; + } - .errorDetails { - color: #8d6363; + .errorDetails { + color: #8d6363; - @include darkTheme { - color: #ba6b6b; + @include darkTheme { + color: #ba6b6b; + } } } } @@ -101,12 +100,11 @@ overflow: hidden; } -.greenButton { - --buttonBg: var(--app-accent-green); - --buttonTextColour: #fff; +.errorName { + font-weight: 550; } -.redButton { - --buttonTextColour: #de5353; - margin-right: auto; +.errorDetails { + margin-top: 6px; + color: var(--secondary_text_color); } diff --git a/shared/studio/tabs/dataview/fieldConfig.module.scss b/shared/studio/tabs/dataview/fieldConfig.module.scss new file mode 100644 index 00000000..2b21c167 --- /dev/null +++ b/shared/studio/tabs/dataview/fieldConfig.module.scss @@ -0,0 +1,244 @@ +@import "@edgedb/common/mixins.scss"; + +.fieldConfigWrapper { + position: relative; + display: flex; + align-items: center; + padding-left: 8px; + height: 48px; + grid-row: 2; +} + +.fieldConfigButton { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + + svg { + color: var(--secondary_text_color); + } + + &:hover { + background: var(--Grey93); + + @include darkTheme { + background: var(--Grey30); + } + } +} + +.fieldConfigPopup { + position: absolute; + top: 8px; + left: 12px; + display: flex; + flex-direction: column; + background: var(--panel_background); + border-radius: 12px; + border: 1px solid var(--panel_border); + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.12), + 0px 2px 4px 0px rgba(0, 0, 0, 0.06); + min-width: 280px; + z-index: 10; + + @include darkTheme { + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.4), + 0px 2px 4px 0px rgba(0, 0, 0, 0.2); + } +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 1; +} + +.listWrapper { + padding: 6px; + overflow: auto; + overscroll-behavior: contain; +} + +.fieldList { + position: relative; + + &.pinnedList { + border-bottom: 1px solid var(--Grey90); + padding-bottom: 4px; + margin-bottom: 4px; + + @include darkTheme { + border-bottom-color: var(--Grey30); + } + } +} + +.fieldItem { + position: relative; + display: flex; + align-items: center; + height: 40px; + margin-bottom: -40px; + border-radius: 8px; + transition: top 0.2s; + + .dragHandle { + display: flex; + height: 100%; + align-items: center; + padding-left: 4px; + padding-right: 2px; + color: var(--tertiary_text_color); + cursor: grab; + } + + &.undraggable .dragHandle { + opacity: 0; + pointer-events: none; + } + + &.dragging { + transition: none; + } + + .details { + display: flex; + flex-direction: column; + padding: 0 24px 0 8px; + font-size: 13px; + font-weight: 450; + color: var(--main_text_color); + + .subtype { + font-size: 12px; + color: var(--tertiary_text_color); + } + } + + &.unselected .details { + opacity: 0.7; + } + + .pin { + display: flex; + margin-left: auto; + padding: 6px 4px; + cursor: pointer; + color: var(--tertiary_text_color); + opacity: 0; + + svg { + height: 20px; + } + + &.pinned { + color: #a15ec0; + opacity: 1; + + @include darkTheme { + color: #c187dc; + } + } + } + + &:hover, + &.dragging { + background: var(--Grey95); + + @include darkTheme { + background: var(--Grey25); + } + } + + &:not(.dragging):hover .pin { + opacity: 1; + } +} + +.selectionControls { + display: flex; + gap: 12px; + font-family: "Roboto Flex Variable", sans-serif; + color: var(--secondary_text_color); + padding: 4px 24px; + font-size: 13px; + font-weight: 500; + border-bottom: 1px solid var(--panel_border); + border-radius: 11px 11px 0 0; + background: var(--Grey97); + line-height: 26px; + + > span { + opacity: 0.5; + } + + @include darkTheme { + background: var(--Grey25); + } +} + +.selectionButton { + display: flex; + align-items: center; + border-radius: 6px; + padding-left: 10px; + cursor: pointer; + + &:hover { + background: var(--Grey93); + + @include darkTheme { + background: var(--Grey30); + } + } + + svg { + height: 22px; + color: var(--tertiary_text_color); + } +} + +.selectionButtonWrapper { + display: flex; + position: relative; + + .dropdown { + position: absolute; + top: calc(100% - 4px); + left: 0; + background: var(--panel_background); + display: flex; + flex-direction: column; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.04), 0 0 4px rgba(0, 0, 0, 0.06); + padding: 4px; + border-radius: 8px; + border: 1px solid var(--panel_border); + width: max-content; + z-index: 1; + cursor: default; + + > div { + padding: 0 8px; + border-radius: 6px; + line-height: 28px; + cursor: pointer; + + &:hover { + background: var(--Grey95); + + @include darkTheme { + background: var(--Grey30); + } + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + } + } +} diff --git a/shared/studio/tabs/dataview/fieldConfig.tsx b/shared/studio/tabs/dataview/fieldConfig.tsx new file mode 100644 index 00000000..1fb23fb9 --- /dev/null +++ b/shared/studio/tabs/dataview/fieldConfig.tsx @@ -0,0 +1,531 @@ +import {useEffect, useRef, useState} from "react"; +import {action, computed, makeObservable, observable, runInAction} from "mobx"; +import {observer} from "mobx-react-lite"; + +import cn from "@edgedb/common/utils/classNames"; + +import { + Checkbox, + PinIcon, + TiltedPinIcon, + DragHandleIcon, + MigrationsListIcon, + ChevronDownIcon, +} from "@edgedb/common/newui"; + +import { + DataInspector, + FieldConfig, + ObjectField, + ObjectFieldType, +} from "./state"; + +import styles from "./fieldConfig.module.scss"; +import {useGlobalDragCursor} from "@edgedb/common/hooks/globalDragCursor"; + +const FieldItemHeight = 40; + +export function FieldConfigButton({state}: {state: DataInspector}) { + const [popupOpen, setPopupOpen] = useState(false); + + if (!state.fieldConfig || !state.allFields) { + return null; + } + + return ( +
+
setPopupOpen(true)} + > + +
+ + {popupOpen ? ( + setPopupOpen(false)} /> + ) : null} +
+ ); +} + +class DraftFieldConfig { + @observable + order: string[]; + + @observable + selected: Set; + + @observable + pinned: Set; + + constructor(config: FieldConfig, public fields: Map) { + makeObservable(this); + + this.order = [...config.order]; + this.selected = new Set(config.selected); + this.pinned = new Set(config.pinned); + } + + @computed + get pinnedOrder() { + return this.order.filter((id) => this.pinned.has(id)); + } + + @computed + get unpinnedOrder() { + return this.order.filter((id) => !this.pinned.has(id)); + } + + @action + toggleSelected(id: string) { + if (this.selected.has(id)) { + this.selected.delete(id); + } else { + this.selected.add(id); + } + } + + @action + togglePinned(id: string) { + if (this.pinned.has(id)) { + this.pinned.delete(id); + } else { + this.pinned.add(id); + } + } + + @observable + draggingItem: {id: string; top: number; pinned: boolean} | null = null; + + @action + updateItemDrag(id: string, top: number) { + const pinned = this.pinned.has(id); + const list = pinned ? this.pinnedOrder : this.unpinnedOrder; + top = Math.min(Math.max(0, top), (list.length - 1) * FieldItemHeight); + const newIndex = Math.floor(top / FieldItemHeight + 0.5); + if (newIndex !== list.indexOf(id)) { + const replaceIndex = this.order.indexOf(list[newIndex]); + this.order.splice(this.order.indexOf(id), 1); + this.order.splice(replaceIndex, 0, id); + } + this.draggingItem = { + id, + top, + pinned, + }; + } + + @action + endItemDrag() { + this.draggingItem = null; + } + + getConfig(): FieldConfig { + return { + order: [...this.order], + selected: new Set(this.selected), + pinned: new Set(this.pinned), + }; + } + + @computed + get fieldTypes() { + return { + all: this.order, + props: this.order.filter((id) => { + const field = this.fields.get(id)!; + return ( + field.type === ObjectFieldType.property && field.computedExpr == null + ); + }), + links: this.order.filter((id) => { + const field = this.fields.get(id)!; + return ( + field.type === ObjectFieldType.link && field.computedExpr == null + ); + }), + computeds: this.order.filter( + (id) => this.fields.get(id)!.computedExpr != null + ), + subtypes: this.order.filter( + (id) => this.fields.get(id)!.subtypeName != null + ), + }; + } + + @computed + get fieldsSelected() { + const fieldTypes = this.fieldTypes; + return { + all: this.selected.size, + props: fieldTypes.props.filter((id) => this.selected.has(id)).length, + links: fieldTypes.links.filter((id) => this.selected.has(id)).length, + computeds: fieldTypes.computeds.filter((id) => this.selected.has(id)) + .length, + subtypes: fieldTypes.subtypes.filter((id) => this.selected.has(id)) + .length, + }; + } + + @action + addToSelected(ids: string[]) { + for (const id of ids) { + this.selected.add(id); + } + } + + @action + removeFromSelected(ids: string[]) { + for (const id of ids) { + this.selected.delete(id); + } + if (this.selected.size === 0) { + this.selected.add( + [...this.fields.values()].find((f) => f.name === "id")!.id + ); + } + } +} + +export function serialiseFieldConfig(config: FieldConfig) { + return JSON.stringify({ + order: config.order, + selected: [...config.selected].map((id) => config.order.indexOf(id)), + pinned: [...config.pinned].map((id) => config.order.indexOf(id)), + }); +} + +export function deserialiseFieldConfig(rawConfig: string): FieldConfig | null { + let configJSON: any; + try { + configJSON = JSON.parse(rawConfig); + if ( + typeof configJSON !== "object" || + !Array.isArray(configJSON.order) || + !Array.isArray(configJSON.selected) || + !Array.isArray(configJSON.pinned) || + !configJSON.order.every((id: any) => typeof id === "string") || + !configJSON.selected.every( + (i: any) => typeof i === "number" && configJSON.order[i] != null + ) || + !configJSON.pinned.every( + (i: any) => typeof i === "number" && configJSON.order[i] != null + ) + ) + return null; + } catch { + return null; + } + + return { + order: configJSON.order, + selected: new Set( + configJSON.selected.map((i: number) => configJSON.order[i]) + ), + pinned: new Set(configJSON.pinned.map((i: number) => configJSON.order[i])), + }; +} + +const FieldConfigPopup = observer(function FieldConfigPopup({ + state, + onClose: _onClose, +}: { + state: DataInspector; + onClose: () => void; +}) { + const ref = useRef(null); + const [draftState] = useState( + () => new DraftFieldConfig(state.fieldConfig!, state.allFields!) + ); + const [maxHeight, setMaxHeight] = useState("100vh"); + + useEffect(() => { + const listener = () => { + const rect = ref.current!.getBoundingClientRect(); + setMaxHeight( + `${window.document.documentElement.clientHeight - rect.top - 24}px` + ); + }; + listener(); + window.addEventListener("resize", listener); + + return () => { + window.removeEventListener("resize", listener); + }; + }, []); + + const onClose = () => { + state.setFieldConfig(draftState.getConfig()); + _onClose(); + }; + + return ( + <> +
+
+
+ { + const types = draftState.fieldTypes; + const selected = draftState.fieldsSelected; + return [ + { + label: "All", + disabled: selected.all === types.all.length, + action: () => draftState.addToSelected(types.all), + }, + { + label: "Properties", + disabled: + selected.props === types.props.length || + !types.props.length, + action: () => draftState.addToSelected(types.props), + }, + { + label: "Links", + disabled: + selected.links === types.links.length || + !types.links.length, + action: () => draftState.addToSelected(types.links), + }, + { + label: "Computeds", + disabled: + selected.computeds === types.computeds.length || + !types.computeds.length, + action: () => draftState.addToSelected(types.computeds), + }, + { + label: "Subtype fields", + disabled: + selected.subtypes === types.subtypes.length || + !types.subtypes.length, + action: () => draftState.addToSelected(types.subtypes), + }, + ]; + }} + /> + / + { + const types = draftState.fieldTypes; + const selected = draftState.fieldsSelected; + return [ + { + label: "All", + disabled: selected.all <= 1, + action: () => draftState.removeFromSelected(types.all), + }, + { + label: "Properties", + disabled: selected.all <= 1 || !selected.props, + action: () => draftState.removeFromSelected(types.props), + }, + { + label: "Links", + disabled: selected.all <= 1 || !selected.links, + action: () => draftState.removeFromSelected(types.links), + }, + { + label: "Computeds", + disabled: selected.all <= 1 || !selected.computeds, + action: () => draftState.removeFromSelected(types.computeds), + }, + { + label: "Subtype fields", + disabled: selected.all <= 1 || !selected.subtypes, + action: () => draftState.removeFromSelected(types.subtypes), + }, + ]; + }} + /> +
+
+ {draftState.pinned.size ? ( +
+ {draftState.pinnedOrder + .filter((id) => draftState.draggingItem?.id !== id) + .map((id) => ( + + ))} + {draftState.draggingItem?.pinned === true ? ( + + ) : null} +
+ ) : null} +
+ {draftState.unpinnedOrder + .filter((id) => draftState.draggingItem?.id !== id) + .map((id) => ( + + ))} + {draftState.draggingItem?.pinned === false ? ( + + ) : null} +
+
+
+ + ); +}); + +const FieldItem = observer(function FieldItem({ + id, + draftState, +}: { + id: string; + draftState: DraftFieldConfig; +}) { + const isDragging = draftState.draggingItem?.id === id; + const isPinned = draftState.pinned.has(id); + const orderList = isPinned + ? draftState.pinnedOrder + : draftState.unpinnedOrder; + const orderIndex = orderList.indexOf(id); + + const field = draftState.fields!.get(id)!; + + const [_, setGlobalDrag] = useGlobalDragCursor(); + + return ( +
+
{ + const startTop = e.clientY; + + const listener = (e: MouseEvent | React.MouseEvent) => { + draftState.updateItemDrag( + id, + orderIndex * FieldItemHeight + e.clientY - startTop + ); + }; + listener(e); + setGlobalDrag("grabbing"); + + window.addEventListener("mousemove", listener); + window.addEventListener( + "mouseup", + () => { + window.removeEventListener("mousemove", listener); + draftState.endItemDrag(); + setGlobalDrag(null); + }, + { + once: true, + } + ); + }} + > + +
+ + draftState.toggleSelected(id)} + disabled={ + draftState.selected.size === 1 && draftState.selected.has(id) + } + /> + +
+ {field.subtypeName ? ( +
{field.subtypeName}
+ ) : null} +
{field.name}
+
+ +
draftState.togglePinned(id)} + > + {isPinned ? : } +
+
+ ); +}); + +function SelectionButton({ + label, + getItems, +}: { + label: string; + getItems: () => {label: string; disabled: boolean; action: () => void}[]; +}) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (open) { + const listener = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + window.addEventListener("mousedown", listener, {capture: true}); + + return () => { + window.removeEventListener("mousedown", listener, {capture: true}); + }; + } + }); + + return ( +
+
setOpen(!open)}> + {label} +
+ {open ? ( +
+ {getItems().map((item, i) => ( +
{ + setOpen(false); + item.action(); + }} + > + {item.label} +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/shared/studio/tabs/dataview/index.tsx b/shared/studio/tabs/dataview/index.tsx index 6032373f..1ba8bbb1 100644 --- a/shared/studio/tabs/dataview/index.tsx +++ b/shared/studio/tabs/dataview/index.tsx @@ -7,8 +7,15 @@ import styles from "./dataview.module.scss"; import {useModal} from "@edgedb/common/hooks/useModal"; import {Theme, useTheme} from "@edgedb/common/hooks/useTheme"; -import {Select} from "@edgedb/common/ui/select"; -import Button from "@edgedb/common/ui/button"; +import { + Button, + Select, + SyncIcon, + ChevronDownIcon, + FilterIcon, + CrossIcon, + CheckIcon, +} from "@edgedb/common/newui"; import {Button as MobButton} from "@edgedb/common/ui/mobile"; import {useTabState, useDatabaseState} from "../../state"; @@ -28,13 +35,7 @@ import {ReviewEditsModal} from "./reviewEditsModal"; import {ObjectTypeSelect} from "../../components/objectTypeSelect"; import {ApplyFilterIcon, BackArrowIcon, ClearFilterIcon} from "./icons"; -import { - BackIcon, - ChevronDownIcon, - FilterIcon, - TabDataExplorerIcon, - WarningIcon, -} from "../../icons"; +import {BackIcon, TabDataExplorerIcon, WarningIcon} from "../../icons"; import {useIsMobile} from "@edgedb/common/hooks/useMobile"; import {CloseButton} from "@edgedb/common/ui/mobile"; @@ -120,7 +121,7 @@ const DataInspectorView = observer(function DataInspectorView({ {!nestedPath ? ( <> inspectorState.toggleEditLinkMode()} > {inspectorState.parentObject.editMode ? "Close edit mode" : "Edit links"} -
+ ) : null} {!isMobile && ( @@ -189,6 +190,13 @@ const DataInspectorView = observer(function DataInspectorView({ <> {inspectorState.rowCount} Item {inspectorState.rowCount === 1 ? "" : "s"} + ) : ( loading... @@ -197,28 +205,17 @@ const DataInspectorView = observer(function DataInspectorView({ )}
- {!isMobile && inspectorState.subTypes.length ? ( - - ) : null} {dataviewState.edits.hasPendingEdits ? ( <> -
openModal() } > Review Changes -
+ ) : null} @@ -228,8 +225,8 @@ const DataInspectorView = observer(function DataInspectorView({ inspectorState.parentObject.editMode) ? ( inspectorState.insertTypeNames.length > 1 ? (