/** * 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, row: Array, 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}) => { 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 ( ); }); 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) => { 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 (
{props.count === props.totalRows ? `${props.count} ` : `${props.currentRow + 1}-${props.currentRow + props.count} `} of {props.totalRows} rows
{state.isOpen ? ( ) : ( )} ); }); 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 ? ( ({ 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, index: number) => transformRow(page.columns, row, index), )} horizontallyScrollable multiHighlight onRowHighlighted={highlightedRowsChanged} onSort={sortOrderChanged} initialSortOrder={currentSort ?? undefined} /> {page.highlightedRows.length === 1 && ( )} ) : 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 ( ({ key: name, visible: true, }))} columns={columns.reduce( (acc, val) => Object.assign({}, acc, {[val]: {value: val, resizable: true}}), {}, )} zebra rows={rows.map((row: Array, index: number) => transformRow(columns, row, index), )} horizontallyScrollable onRowHighlighted={highlightedRowsChanged} /> {table.highlightedRows.length === 1 && ( )} ); } else if (query.id && query.id !== null) { return ( Row id: {query.id} ); } else if (query.count && query.count !== null) { return ( Rows affected: {query.count} ); } 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 ( {favorites.map((q) => ( {q} ))} ); }, ); 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) .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) => ( )), [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) => ( )) : [], [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 ( Data Structure SQL Table Info Query History {state.viewMode === 'data' || state.viewMode === 'structure' || state.viewMode === 'tableInfo' ? ( Database Table
) : null} {state.viewMode === 'SQL' ? ( Database