desktop/plugins/public/databases/DatabasesPlugin.tsx (750 lines of code) (raw):
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
ManagedTable,
TableBodyColumn,
TableRows,
TableBodyRow,
TableRowSortOrder,
TableHighlightedRows,
} from 'flipper';
import {
DatabaseEntry,
Page,
plugin,
Query,
QueryResult,
Structure,
} from './index';
import {getStringFromErrorLike} from './utils';
import {Value, renderValue} from './TypeBasedValueRenderer';
import React, {KeyboardEvent, ChangeEvent, useState, useCallback} from 'react';
import ButtonNavigation from './ButtonNavigation';
import DatabaseDetailSidebar from './DatabaseDetailSidebar';
import DatabaseStructure from './DatabaseStructure';
import {
convertStringToValue,
constructUpdateQuery,
isUpdatable,
} from './UpdateQueryUtil';
import sqlFormatter from 'sql-formatter';
import {
usePlugin,
useValue,
Layout,
useMemoize,
Toolbar,
theme,
styled,
produce,
} from 'flipper-plugin';
import {
Select,
Radio,
RadioChangeEvent,
Typography,
Button,
Menu,
Dropdown,
Input,
} from 'antd';
import {
ConsoleSqlOutlined,
DatabaseOutlined,
DownOutlined,
HistoryOutlined,
SettingOutlined,
StarFilled,
StarOutlined,
TableOutlined,
} from '@ant-design/icons';
const {TextArea} = Input;
const {Option} = Select;
const {Text} = Typography;
const BoldSpan = styled.span({
fontSize: 12,
color: '#90949c',
fontWeight: 'bold',
textTransform: 'uppercase',
});
const ErrorBar = styled.div({
backgroundColor: theme.errorColor,
color: theme.textColorPrimary,
lineHeight: '26px',
textAlign: 'center',
});
const PageInfoContainer = styled(Layout.Horizontal)({alignItems: 'center'});
function transformRow(
columns: Array<string>,
row: Array<Value>,
index: number,
): TableBodyRow {
const transformedColumns: {[key: string]: TableBodyColumn} = {};
for (let i = 0; i < columns.length; i++) {
transformedColumns[columns[i]] = {value: renderValue(row[i], true)};
}
return {key: String(index), columns: transformedColumns};
}
const QueryHistory = React.memo(({history}: {history: Array<Query>}) => {
if (!history || typeof history === 'undefined') {
return null;
}
const columns = {
time: {
value: 'Time',
resizable: true,
},
query: {
value: 'Query',
resizable: true,
},
};
const rows: TableRows = [];
if (history.length > 0) {
for (let i = 0; i < history.length; i++) {
const query = history[i];
const time = query.time;
const value = query.value;
rows.push({
key: `${i}`,
columns: {time: {value: time}, query: {value: value}},
});
}
}
return (
<Layout.Horizontal grow>
<ManagedTable
floating={false}
columns={columns}
columnSizes={{time: 75}}
zebra
rows={rows}
horizontallyScrollable
/>
</Layout.Horizontal>
);
});
type PageInfoProps = {
currentRow: number;
count: number;
totalRows: number;
onChange: (currentRow: number, count: number) => void;
};
const PageInfo = React.memo((props: PageInfoProps) => {
const [state, setState] = useState({
isOpen: false,
inputValue: String(props.currentRow),
});
const onOpen = useCallback(() => {
setState({...state, isOpen: true});
}, [state]);
const onInputChanged = useCallback(
(e: ChangeEvent<any>) => {
setState({...state, inputValue: e.target.value});
},
[state],
);
const onSubmit = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Enter') {
const rowNumber = parseInt(state.inputValue, 10);
props.onChange(rowNumber - 1, props.count);
setState({...state, isOpen: false});
}
},
[props, state],
);
return (
<PageInfoContainer grow>
<div style={{flex: 1}} />
<Text>
{props.count === props.totalRows
? `${props.count} `
: `${props.currentRow + 1}-${props.currentRow + props.count} `}
of {props.totalRows} rows
</Text>
<div style={{flex: 1}} />
{state.isOpen ? (
<Input
tabIndex={-1}
placeholder={(props.currentRow + 1).toString()}
onChange={onInputChanged}
onKeyDown={onSubmit}
/>
) : (
<Button style={{textAlign: 'center'}} onClick={onOpen}>
Go To Row
</Button>
)}
</PageInfoContainer>
);
});
const DataTable = React.memo(
({
page,
highlightedRowsChanged,
sortOrderChanged,
currentSort,
currentStructure,
onRowEdited,
}: {
page: Page | null;
highlightedRowsChanged: (highlightedRows: TableHighlightedRows) => void;
sortOrderChanged: (sortOrder: TableRowSortOrder) => void;
currentSort: TableRowSortOrder | null;
currentStructure: Structure | null;
onRowEdited: (changes: {[key: string]: string | null}) => void;
}) =>
page && page.columns ? (
<Layout.Horizontal grow>
<ManagedTable
tableKey={`databases-${page.databaseId}-${page.table}`}
floating={false}
columnOrder={page.columns.map((name) => ({
key: name,
visible: true,
}))}
columns={page.columns.reduce(
(acc, val) =>
Object.assign({}, acc, {
[val]: {value: val, resizable: true, sortable: true},
}),
{},
)}
zebra
rows={page.rows.map((row: Array<Value>, index: number) =>
transformRow(page.columns, row, index),
)}
horizontallyScrollable
multiHighlight
onRowHighlighted={highlightedRowsChanged}
onSort={sortOrderChanged}
initialSortOrder={currentSort ?? undefined}
/>
{page.highlightedRows.length === 1 && (
<DatabaseDetailSidebar
columnLabels={page.columns}
columnValues={page.rows[page.highlightedRows[0]]}
onSave={
currentStructure &&
isUpdatable(currentStructure.columns, currentStructure.rows)
? onRowEdited
: undefined
}
/>
)}
</Layout.Horizontal>
) : null,
);
const QueryTable = React.memo(
({
query,
highlightedRowsChanged,
}: {
query: QueryResult | null;
highlightedRowsChanged: (highlightedRows: TableHighlightedRows) => void;
}) => {
if (!query || query === null) {
return null;
}
if (
query.table &&
typeof query.table !== 'undefined' &&
query.table !== null
) {
const table = query.table;
const columns = table.columns;
const rows = table.rows;
return (
<Layout.Container grow>
<ManagedTable
floating={false}
multiline
columnOrder={columns.map((name) => ({
key: name,
visible: true,
}))}
columns={columns.reduce(
(acc, val) =>
Object.assign({}, acc, {[val]: {value: val, resizable: true}}),
{},
)}
zebra
rows={rows.map((row: Array<Value>, index: number) =>
transformRow(columns, row, index),
)}
horizontallyScrollable
onRowHighlighted={highlightedRowsChanged}
/>
{table.highlightedRows.length === 1 && (
<DatabaseDetailSidebar
columnLabels={table.columns}
columnValues={table.rows[table.highlightedRows[0]]}
/>
)}
</Layout.Container>
);
} else if (query.id && query.id !== null) {
return (
<Layout.Horizontal grow pad>
<Text>Row id: {query.id}</Text>
</Layout.Horizontal>
);
} else if (query.count && query.count !== null) {
return (
<Layout.Horizontal grow pad>
<Text>Rows affected: {query.count}</Text>
</Layout.Horizontal>
);
} else {
return null;
}
},
);
const FavoritesMenu = React.memo(
({
favorites,
onClick,
}: {
favorites: string[];
onClick: (value: string) => void;
}) => {
const onMenuClick = useCallback(
(p: any) => onClick(p.key as string),
[onClick],
);
return (
<Menu>
{favorites.map((q) => (
<Menu.Item key={q} onClick={onMenuClick}>
{q}
</Menu.Item>
))}
</Menu>
);
},
);
export function Component() {
const instance = usePlugin(plugin);
const state = useValue(instance.state);
const favorites = useValue(instance.favoritesState);
const onViewModeChanged = useCallback(
(evt: RadioChangeEvent) => {
instance.updateViewMode({viewMode: evt.target.value ?? 'data'});
},
[instance],
);
const onDataClicked = useCallback(() => {
instance.updateViewMode({viewMode: 'data'});
}, [instance]);
const onStructureClicked = useCallback(() => {
instance.updateViewMode({viewMode: 'structure'});
}, [instance]);
const onSQLClicked = useCallback(() => {
instance.updateViewMode({viewMode: 'SQL'});
}, [instance]);
const onTableInfoClicked = useCallback(() => {
instance.updateViewMode({viewMode: 'tableInfo'});
}, [instance]);
const onQueryHistoryClicked = useCallback(() => {
instance.updateViewMode({viewMode: 'queryHistory'});
}, [instance]);
const onRefreshClicked = useCallback(() => {
instance.state.update((state) => {
state.error = null;
});
instance.refresh();
}, [instance]);
const onFavoriteButtonClicked = useCallback(() => {
if (state.query) {
instance.addOrRemoveQueryToFavorites(state.query.value);
}
}, [instance, state.query]);
const onDatabaseSelected = useCallback(
(selected: string) => {
const dbId =
instance.state.get().databases.find((x) => x.name === selected)?.id ||
0;
instance.updateSelectedDatabase({
database: dbId,
});
},
[instance],
);
const onDatabaseTableSelected = useCallback(
(selected: string) => {
instance.updateSelectedDatabaseTable({
table: selected,
});
},
[instance],
);
const onNextPageClicked = useCallback(() => {
instance.nextPage();
}, [instance]);
const onPreviousPageClicked = useCallback(() => {
instance.previousPage();
}, [instance]);
const onExecuteClicked = useCallback(() => {
const query = instance.state.get().query;
if (query) {
instance.execute({query: query.value});
}
}, [instance]);
const onQueryTextareaKeyPress = useCallback(
(event: KeyboardEvent) => {
// Implement ctrl+enter as a shortcut for clicking 'Execute'.
if (event.key === '\n' && event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
onExecuteClicked();
}
},
[onExecuteClicked],
);
const onGoToRow = useCallback(
(row: number, _count: number) => {
instance.goToRow({row: row});
},
[instance],
);
const onQueryChanged = useCallback(
(selected: any) => {
instance.updateQuery({
value: selected.target.value,
});
},
[instance],
);
const onFavoriteQuerySelected = useCallback(
(query: string) => {
instance.updateQuery({
value: query,
});
},
[instance],
);
const pageHighlightedRowsChanged = useCallback(
(rows: TableHighlightedRows) => {
instance.pageHighlightedRowsChanged(rows);
},
[instance],
);
const queryHighlightedRowsChanged = useCallback(
(rows: TableHighlightedRows) => {
instance.queryHighlightedRowsChanged(rows);
},
[instance],
);
const sortOrderChanged = useCallback(
(sortOrder: TableRowSortOrder) => {
instance.sortByChanged({sortOrder});
},
[instance],
);
const onRowEdited = useCallback(
(change: {[key: string]: string | null}) => {
const {selectedDatabaseTable, currentStructure, viewMode, currentPage} =
instance.state.get();
const highlightedRowIdx = currentPage?.highlightedRows[0] ?? -1;
const row =
highlightedRowIdx >= 0
? currentPage?.rows[currentPage?.highlightedRows[0]]
: undefined;
const columns = currentPage?.columns;
// currently only allow to edit data shown in Data tab
if (
viewMode !== 'data' ||
selectedDatabaseTable === null ||
currentStructure === null ||
currentPage === null ||
row === undefined ||
columns === undefined ||
// only trigger when there is change
Object.keys(change).length <= 0
) {
return;
}
// check if the table has primary key to use for query
// This is assumed data are in the same format as in SqliteDatabaseDriver.java
const primaryKeyIdx = currentStructure.columns.indexOf('primary_key');
const nameKeyIdx = currentStructure.columns.indexOf('column_name');
const typeIdx = currentStructure.columns.indexOf('data_type');
const nullableIdx = currentStructure.columns.indexOf('nullable');
if (primaryKeyIdx < 0 && nameKeyIdx < 0 && typeIdx < 0) {
console.error(
'primary_key, column_name, and/or data_type cannot be empty',
);
return;
}
const primaryColumnIndexes = currentStructure.rows
.reduce((acc, row) => {
const primary = row[primaryKeyIdx];
if (primary.type === 'boolean' && primary.value) {
const name = row[nameKeyIdx];
return name.type === 'string' ? acc.concat(name.value) : acc;
} else {
return acc;
}
}, [] as Array<string>)
.map((name) => columns.indexOf(name))
.filter((idx) => idx >= 0);
// stop if no primary key to distinguish unique query
if (primaryColumnIndexes.length <= 0) {
return;
}
const types = currentStructure.rows.reduce((acc, row) => {
const nameValue = row[nameKeyIdx];
const name = nameValue.type === 'string' ? nameValue.value : null;
const typeValue = row[typeIdx];
const type = typeValue.type === 'string' ? typeValue.value : null;
const nullableValue =
nullableIdx < 0 ? {type: 'null', value: null} : row[nullableIdx];
const nullable = nullableValue.value !== false;
if (name !== null && type !== null) {
acc[name] = {type, nullable};
}
return acc;
}, {} as {[key: string]: {type: string; nullable: boolean}});
const changeValue = Object.entries(change).reduce(
(acc, [key, value]: [string, string | null]) => {
acc[key] = convertStringToValue(types, key, value);
return acc;
},
{} as {[key: string]: Value},
);
instance.execute({
query: constructUpdateQuery(
selectedDatabaseTable,
primaryColumnIndexes.reduce((acc, idx) => {
acc[columns[idx]] = row[idx];
return acc;
}, {} as {[key: string]: Value}),
changeValue,
),
});
instance.updatePage({
...produce(currentPage, (draft) =>
Object.entries(changeValue).forEach(
([key, value]: [string, Value]) => {
const columnIdx = draft.columns.indexOf(key);
if (columnIdx >= 0) {
draft.rows[highlightedRowIdx][columnIdx] = value;
}
},
),
),
});
},
[instance],
);
const databaseOptions = useMemoize(
(databases) =>
databases.map((x) => (
<Option key={x.name} value={x.name} label={x.name}>
{x.name}
</Option>
)),
[state.databases],
);
const selectedDatabaseName = useMemoize(
(selectedDatabase: number, databases: DatabaseEntry[]) =>
selectedDatabase && databases[state.selectedDatabase - 1]
? databases[selectedDatabase - 1].name
: undefined,
[state.selectedDatabase, state.databases],
);
const tableOptions = useMemoize(
(selectedDatabase: number, databases: DatabaseEntry[]) =>
selectedDatabase && databases[state.selectedDatabase - 1]
? databases[selectedDatabase - 1].tables.map((tableName) => (
<Option key={tableName} value={tableName} label={tableName}>
{tableName}
</Option>
))
: [],
[state.selectedDatabase, state.databases],
);
const selectedTableName = useMemoize(
(
selectedDatabase: number,
databases: DatabaseEntry[],
selectedDatabaseTable: string | null,
) =>
selectedDatabase && databases[selectedDatabase - 1]
? databases[selectedDatabase - 1].tables.find(
(t) => t === selectedDatabaseTable,
) ?? databases[selectedDatabase - 1].tables[0]
: undefined,
[state.selectedDatabase, state.databases, state.selectedDatabaseTable],
);
return (
<Layout.Container grow>
<Toolbar position="top">
<Radio.Group value={state.viewMode} onChange={onViewModeChanged}>
<Radio.Button value="data" onClick={onDataClicked}>
<TableOutlined style={{marginRight: 5}} />
<Typography.Text>Data</Typography.Text>
</Radio.Button>
<Radio.Button onClick={onStructureClicked} value="structure">
<SettingOutlined style={{marginRight: 5}} />
<Typography.Text>Structure</Typography.Text>
</Radio.Button>
<Radio.Button onClick={onSQLClicked} value="SQL">
<ConsoleSqlOutlined style={{marginRight: 5}} />
<Typography.Text>SQL</Typography.Text>
</Radio.Button>
<Radio.Button onClick={onTableInfoClicked} value="tableInfo">
<DatabaseOutlined style={{marginRight: 5}} />
<Typography.Text>Table Info</Typography.Text>
</Radio.Button>
<Radio.Button onClick={onQueryHistoryClicked} value="queryHistory">
<HistoryOutlined style={{marginRight: 5}} />
<Typography.Text>Query History</Typography.Text>
</Radio.Button>
</Radio.Group>
</Toolbar>
{state.viewMode === 'data' ||
state.viewMode === 'structure' ||
state.viewMode === 'tableInfo' ? (
<Toolbar position="top">
<BoldSpan>Database</BoldSpan>
<Select
showSearch
value={selectedDatabaseName}
onChange={onDatabaseSelected}
style={{flex: 1}}
dropdownMatchSelectWidth={false}>
{databaseOptions}
</Select>
<BoldSpan>Table</BoldSpan>
<Select
showSearch
value={selectedTableName}
onChange={onDatabaseTableSelected}
style={{flex: 1}}
dropdownMatchSelectWidth={false}>
{tableOptions}
</Select>
<div />
<Button onClick={onRefreshClicked} type="default">
Refresh
</Button>
</Toolbar>
) : null}
{state.viewMode === 'SQL' ? (
<Layout.Container>
<Toolbar position="top">
<BoldSpan>Database</BoldSpan>
<Select
showSearch
value={selectedDatabaseName}
onChange={onDatabaseSelected}
dropdownMatchSelectWidth={false}>
{databaseOptions}
</Select>
</Toolbar>
<Layout.Horizontal pad={theme.space.small} style={{paddingBottom: 0}}>
<TextArea
onChange={onQueryChanged}
onKeyPress={onQueryTextareaKeyPress}
placeholder="Type query here.."
value={
state.query !== null && typeof state.query !== 'undefined'
? state.query.value
: undefined
}
/>
</Layout.Horizontal>
<Toolbar position="top">
<Layout.Right>
<div />
<Layout.Horizontal gap={theme.space.small}>
<Button
icon={
state.query && favorites.includes(state.query.value) ? (
<StarFilled />
) : (
<StarOutlined />
)
}
onClick={onFavoriteButtonClicked}
/>
<Dropdown
overlay={
<FavoritesMenu
favorites={favorites}
onClick={onFavoriteQuerySelected}
/>
}>
<Button onClick={() => {}}>
Choose from previous queries <DownOutlined />
</Button>
</Dropdown>
<Button
type="primary"
onClick={onExecuteClicked}
title={'Execute SQL [Ctrl+Return]'}>
Execute
</Button>
</Layout.Horizontal>
</Layout.Right>
</Toolbar>
</Layout.Container>
) : null}
<Layout.Container grow>
{state.viewMode === 'data' ? (
<DataTable
page={state.currentPage}
highlightedRowsChanged={pageHighlightedRowsChanged}
onRowEdited={onRowEdited}
sortOrderChanged={sortOrderChanged}
currentSort={state.currentSort}
currentStructure={state.currentStructure}
/>
) : null}
{state.viewMode === 'structure' && state.currentStructure ? (
<DatabaseStructure structure={state.currentStructure} />
) : null}
{state.viewMode === 'SQL' ? (
<QueryTable
query={state.queryResult}
highlightedRowsChanged={queryHighlightedRowsChanged}
/>
) : null}
{state.viewMode === 'tableInfo' ? (
<Layout.Horizontal
grow
pad={theme.space.small}
style={{paddingBottom: 0}}>
<TextArea value={sqlFormatter.format(state.tableInfo)} readOnly />
</Layout.Horizontal>
) : null}
{state.viewMode === 'queryHistory' ? (
<QueryHistory history={state.queryHistory} />
) : null}
</Layout.Container>
<Toolbar position="bottom" style={{paddingLeft: 8}}>
<Layout.Horizontal grow>
{state.viewMode === 'SQL' && state.executionTime !== 0 ? (
<Text> {state.executionTime} ms </Text>
) : null}
{state.viewMode === 'data' && state.currentPage ? (
<PageInfo
currentRow={state.currentPage.start}
count={state.currentPage.count}
totalRows={state.currentPage.total}
onChange={onGoToRow}
/>
) : null}
{state.viewMode === 'data' && state.currentPage ? (
<ButtonNavigation
canGoBack={state.currentPage.start > 0}
canGoForward={
state.currentPage.start + state.currentPage.count <
state.currentPage.total
}
onBack={onPreviousPageClicked}
onForward={onNextPageClicked}
/>
) : null}
</Layout.Horizontal>
</Toolbar>
{state.error && (
<ErrorBar>{getStringFromErrorLike(state.error)}</ErrorBar>
)}
</Layout.Container>
);
}