client/app/components/queries/SchemaBrowser.jsx (250 lines of code) (raw):
import { isNil, map, filter, some, includes, get } from "lodash";
import cx from "classnames";
import React, { useState, useCallback, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
import Input from "antd/lib/input";
import Button from "antd/lib/button";
import AutoSizer from "react-virtualized/dist/commonjs/AutoSizer";
import List from "react-virtualized/dist/commonjs/List";
import PlainButton from "@/components/PlainButton";
import Tooltip from "@/components/Tooltip";
import useDataSourceSchema from "@/pages/queries/hooks/useDataSourceSchema";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import LoadingState from "../items-list/components/LoadingState";
const SchemaItemColumnType = PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string,
});
export const SchemaItemType = PropTypes.shape({
name: PropTypes.string.isRequired,
size: PropTypes.number,
loading: PropTypes.bool,
columns: PropTypes.arrayOf(SchemaItemColumnType).isRequired,
});
const schemaTableHeight = 22;
const schemaColumnHeight = 18;
function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
const handleSelect = useCallback(
(event, ...args) => {
event.preventDefault();
event.stopPropagation();
onSelect(...args);
},
[onSelect]
);
if (!item) {
return null;
}
const tableDisplayName = item.displayName || item.name;
return (
<div {...props}>
<div className="schema-list-item">
<PlainButton className="table-name" onClick={onToggle}>
<i className="fa fa-table m-r-5" aria-hidden="true" />
<strong>
<span title={item.name}>{tableDisplayName}</span>
{!isNil(item.size) && <span> ({item.size})</span>}
</strong>
</PlainButton>
<Tooltip
title="Insert table name into query text"
mouseEnterDelay={0}
mouseLeaveDelay={0}
placement="topRight"
arrowPointAtCenter>
<PlainButton className="copy-to-editor" onClick={e => handleSelect(e, item.name)}>
<i className="fa fa-angle-double-right" aria-hidden="true" />
</PlainButton>
</Tooltip>
</div>
{expanded && (
<div className="table-open">
{item.loading ? (
<div className="table-open">Loading...</div>
) : (
map(item.columns, column => {
const columnName = get(column, "name");
const columnType = get(column, "type");
return (
<Tooltip
title="Insert column name into query text"
mouseEnterDelay={0}
mouseLeaveDelay={0}
placement="rightTop">
<PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}>
<div>
{columnName} {columnType && <span className="column-type">{columnType}</span>}
</div>
<div className="copy-to-editor">
<i className="fa fa-angle-double-right" aria-hidden="true" />
</div>
</PlainButton>
</Tooltip>
);
})
)}
</div>
)}
</div>
);
}
SchemaItem.propTypes = {
item: SchemaItemType,
expanded: PropTypes.bool,
onToggle: PropTypes.func,
onSelect: PropTypes.func,
};
SchemaItem.defaultProps = {
item: null,
expanded: false,
onToggle: () => {},
onSelect: () => {},
};
function SchemaLoadingState() {
return (
<div className="schema-loading-state">
<LoadingState className="" />
</div>
);
}
export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onItemSelect }) {
const [listRef, setListRef] = useState(null);
useEffect(() => {
if (listRef) {
listRef.recomputeRowHeights();
}
}, [listRef, schema, expandedFlags]);
return (
<div className="schema-browser">
{loading && <SchemaLoadingState />}
{!loading && (
<AutoSizer>
{({ width, height }) => (
<List
ref={setListRef}
width={width}
height={height}
rowCount={schema.length}
rowHeight={({ index }) => {
const item = schema[index];
const columnsLength = !item.loading ? item.columns.length : 1;
let columnCount = expandedFlags[item.name] ? columnsLength : 0;
return schemaTableHeight + schemaColumnHeight * columnCount;
}}
rowRenderer={({ key, index, style }) => {
const item = schema[index];
return (
<SchemaItem
key={key}
style={style}
item={item}
expanded={expandedFlags[item.name]}
onToggle={() => onTableExpand(item.name)}
onSelect={onItemSelect}
/>
);
}}
/>
)}
</AutoSizer>
)}
</div>
);
}
export function applyFilterOnSchema(schema, filterString) {
const filters = filter(filterString.toLowerCase().split(/\s+/), s => s.length > 0);
// Empty string: return original schema
if (filters.length === 0) {
return schema;
}
// Single word: matches table or column
if (filters.length === 1) {
const nameFilter = filters[0];
const columnFilter = filters[0];
return filter(
schema,
item =>
includes(item.name.toLowerCase(), nameFilter) ||
some(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter))
);
}
// Two (or more) words: first matches table, seconds matches column
const nameFilter = filters[0];
const columnFilter = filters[1];
return filter(
map(schema, item => {
if (includes(item.name.toLowerCase(), nameFilter)) {
item = {
...item,
columns: filter(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)),
};
return item.columns.length > 0 ? item : null;
}
})
);
}
export default function SchemaBrowser({
dataSource,
onSchemaUpdate,
onItemSelect,
options,
onOptionsUpdate,
...props
}) {
const [schema, isLoading, refreshSchema] = useDataSourceSchema(dataSource);
const [filterString, setFilterString] = useState("");
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
const [expandedFlags, setExpandedFlags] = useState({});
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
useEffect(() => {
setExpandedFlags({});
handleSchemaUpdate(schema);
}, [schema, handleSchemaUpdate]);
if (schema.length === 0 && !isLoading) {
return null;
}
function toggleTable(tableName) {
setExpandedFlags({
...expandedFlags,
[tableName]: !expandedFlags[tableName],
});
}
return (
<div className="schema-container" {...props}>
<div className="schema-control">
<Input
className="m-r-5"
placeholder="Search schema..."
aria-label="Search schema"
disabled={schema.length === 0}
onChange={event => handleFilterChange(event.target.value)}
/>
<Tooltip title="Refresh Schema">
<Button onClick={() => refreshSchema(true)}>
<i className={cx("zmdi zmdi-refresh", { "zmdi-hc-spin": isLoading })} aria-hidden="true" />
<span className="sr-only">{isLoading ? "Loading, please wait." : "Press to refresh."}</span>
</Button>
</Tooltip>
</div>
<SchemaList
loading={isLoading && schema.length === 0}
schema={filteredSchema}
expandedFlags={expandedFlags}
onTableExpand={toggleTable}
onItemSelect={onItemSelect}
/>
</div>
);
}
SchemaBrowser.propTypes = {
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onSchemaUpdate: PropTypes.func,
onItemSelect: PropTypes.func,
};
SchemaBrowser.defaultProps = {
dataSource: null,
onSchemaUpdate: () => {},
onItemSelect: () => {},
};