diff --git a/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx b/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx index 2223d880b..064dc0124 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx @@ -36,7 +36,7 @@ import React, { Fragment, FunctionComponent, useCallback, useEffect, useRef, use import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; import { useRecoilState, useRecoilValue } from 'recoil'; import { filter } from 'rxjs/operators'; -import { Query } from 'soql-parser-js'; +import { FieldSubquery, Query, composeQuery, isFieldSubquery, parseQuery } from 'soql-parser-js'; import { applicationCookieState, selectedOrgState } from '../../../app-state'; import ViewEditCloneRecord from '../../core/ViewEditCloneRecord'; import { useAmplitude } from '../../core/analytics'; @@ -94,7 +94,6 @@ export const QueryResults: FunctionComponent = React.memo(() const [records, setRecords] = useState(null); const [nextRecordsUrl, setNextRecordsUrl] = useState>(null); const [fields, setFields] = useState(null); - const [modifiedFields, setModifiedFields] = useState(null); const [subqueryFields, setSubqueryFields] = useState>>(null); const [filteredRows, setFilteredRows] = useState([]); const [selectedRows, setSelectedRows] = useState([]); @@ -178,15 +177,29 @@ export const QueryResults: FunctionComponent = React.memo(() } }, [isTooling, soqlPanelOpen, trackEvent]); + // Ensure that the query is updated in the browser history any time it changes + useNonInitialEffect(() => { + if (soql) { + window.history.replaceState({ state: { soql, isTooling } }, ''); + } + }, [isTooling, soql]); + useEffect(() => { logger.log({ location }); - if (locationState) { - setSoql(locationState.soql || ''); + if (locationState && locationState.soql) { + setSoql(locationState.soql); setIsTooling(locationState.isTooling ? true : false); - locationState.soql && - executeQuery(locationState.soql, locationState.fromHistory ? SOURCE_HISTORY : SOURCE_STANDARD, { - isTooling: locationState.isTooling, - }); + + try { + const parsedQuery = parseQuery(locationState.soql); + setParsedQuery(parsedQuery); + } catch (ex) { + logger.warn('Could not parse query from location state', locationState.soql, ex.message); + } + + executeQuery(locationState.soql, locationState.fromHistory ? SOURCE_HISTORY : SOURCE_STANDARD, { + isTooling: locationState.isTooling, + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [location]); @@ -254,12 +267,12 @@ export const QueryResults: FunctionComponent = React.memo(() } setRecords(null); setRecordCount(null); - // setFields(null); setSubqueryFields(null); const results = await query(selectedOrg, soqlQuery, tooling, !tooling && includeDeletedRecords); if (!isMounted.current) { return; } + setParsedQuery(results.parsedQuery); setQueryResults(results); setNextRecordsUrl(results.queryResults.nextRecordsUrl); setRecordCount(results.queryResults.totalSize); @@ -286,7 +299,6 @@ export const QueryResults: FunctionComponent = React.memo(() const sobjectName = results.parsedQuery?.sObject || results.columns?.entityName; sobjectName && (await saveQueryHistory(soqlQuery, sobjectName, tooling)); setSobject(sobjectName); - setParsedQuery(results.parsedQuery); } catch (ex) { if (!isMounted.current) { return; @@ -417,9 +429,50 @@ export const QueryResults: FunctionComponent = React.memo(() } } - function handleFieldsChanged({ allFields, visibleFields }: { allFields: string[]; visibleFields: string[] }) { - setFields(allFields); - setModifiedFields(visibleFields); + function handleFieldsChanged(newFields: string[], columnOrder: number[]) { + try { + setFields(newFields); + if (newFields?.length && parsedQuery?.fields && Array.isArray(parsedQuery.fields)) { + const newParsedQuery = { + ...parsedQuery, + fields: columnOrder.map((idx) => parsedQuery.fields![idx]), + }; + setParsedQuery(newParsedQuery); + setSoql(composeQuery(newParsedQuery, { format: true })); + } + } catch (ex) { + logger.warn('Error setting query after fields changed', ex.message); + } + } + + function handleSubqueryFieldsChanged(columnKey: string, newFields: string[], columnOrder: number[]) { + try { + const subqueryIdx = fields?.findIndex((field) => field === columnKey) || -1; + if ( + subqueryIdx >= 0 && + newFields?.length && + parsedQuery?.fields && + Array.isArray(parsedQuery.fields) && + isFieldSubquery(parsedQuery.fields[subqueryIdx]) + ) { + const subqueryColumn = { ...(parsedQuery.fields[subqueryIdx] as FieldSubquery) }; + + subqueryColumn.subquery = { + ...subqueryColumn.subquery, + fields: columnOrder.map((idx) => subqueryColumn.subquery.fields![idx]), + }; + + const newParsedQuery = { + ...parsedQuery, + fields: parsedQuery.fields.map((field, idx) => (idx === subqueryIdx ? subqueryColumn : field)), + }; + setParsedQuery(newParsedQuery); + + setSoql(composeQuery(newParsedQuery, { format: true })); + } + } catch (ex) { + logger.warn('Error setting query after fields changed (Subquery)', ex.message); + } } function handleOpenHistory(type: fromQueryHistory.QueryHistoryType) { @@ -496,7 +549,7 @@ export const QueryResults: FunctionComponent = React.memo(() = React.memo(() isTooling={isTooling} nextRecordsUrl={nextRecordsUrl} fields={fields || []} - modifiedFields={modifiedFields || []} subqueryFields={subqueryFields} records={records || []} filteredRows={filteredRows} @@ -618,6 +670,7 @@ export const QueryResults: FunctionComponent = React.memo(() } onSelectionChanged={setSelectedRows} onFields={handleFieldsChanged} + onSubqueryFieldReorder={handleSubqueryFieldsChanged} onFilteredRowsChanged={setFilteredRows} onLoadMoreRecords={handleLoadMore} onSavedRecords={(data) => { diff --git a/apps/jetstream/src/app/components/query/QueryResults/QueryResultsCopyToClipboard.tsx b/apps/jetstream/src/app/components/query/QueryResults/QueryResultsCopyToClipboard.tsx index 98c37c726..ded9ad982 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/QueryResultsCopyToClipboard.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/QueryResultsCopyToClipboard.tsx @@ -89,7 +89,6 @@ export const QueryResultsCopyToClipboard: FunctionComponent - {/* TODO */} ; fields: string[]; - modifiedFields: string[]; subqueryFields: Maybe>; records: any[]; filteredRows: any[]; @@ -35,7 +34,6 @@ export const QueryResultsDownloadButton: FunctionComponent> - extends Omit, 'columns' | 'rows' | 'rowKeyGetter'> { + extends Omit, 'columns' | 'rows' | 'rowKeyGetter' | 'onColumnsReorder'> { data: T[]; columns: ColumnWithFilter[]; serverUrl?: string; @@ -24,7 +24,7 @@ export interface DataTableProps> getRowKey: (row: T) => string; rowAlwaysVisible?: (row: T) => boolean; ignoreRowInSetFilter?: (row: T) => boolean; - onReorderColumns?: (columns: string[]) => void; + onReorderColumns?: (columns: string[], columnOrder: number[]) => void; onSortedAndFilteredRowsChange?: (rows: readonly T[]) => void; } diff --git a/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx b/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx index 0cf6e281e..b94291a96 100644 --- a/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx +++ b/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx @@ -3,7 +3,7 @@ import { copyRecordsToClipboard, formatNumber } from '@jetstream/shared/ui-utils import { flattenRecord } from '@jetstream/shared/utils'; import { Maybe, SalesforceOrgUi } from '@jetstream/types'; import type { QueryResult } from 'jsforce'; -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { RenderCellProps } from 'react-data-grid'; import RecordDownloadModal from '../file-download-modal/RecordDownloadModal'; import Grid from '../grid/Grid'; @@ -14,14 +14,7 @@ import Icon from '../widgets/Icon'; import Spinner from '../widgets/Spinner'; import { DataTable } from './DataTable'; import { DataTableSubqueryContext } from './data-table-context'; -import { - ColumnWithFilter, - ContextAction, - ContextMenuActionData, - RowWithKey, - SalesforceQueryColumnDefinition, - SubqueryContext, -} from './data-table-types'; +import { ColumnWithFilter, ContextAction, ContextMenuActionData, RowWithKey, SubqueryContext } from './data-table-types'; import { NON_DATA_COLUMN_KEYS, TABLE_CONTEXT_MENU_ITEMS, @@ -97,8 +90,7 @@ export const SubqueryRenderer: FunctionComponent[]) { - const fields = columns.map((column) => column.key); + function handleCopyToClipboard(fields: string[]) { copyRecordsToClipboard(records, 'excel', fields); } @@ -133,13 +125,20 @@ export const SubqueryRenderer: FunctionComponent {(downloadModalIsActive || isActive) && ( []; modalTagline?: Maybe; queryResults: QueryResult; isLoadingMore: boolean; selectedRows: ReadonlySet; downloadModalIsActive: boolean; + onSubqueryFieldReorder?: (columnKey: string, fields: string[], columnOrder: number[]) => void; loadMore: (org: SalesforceOrgUi, isTooling: boolean) => void; openDownloadModal: () => void; handleCloseModal: (cancelled?: boolean) => void; - handleCopyToClipboard: (columns: ColumnWithFilter[]) => void; + handleCopyToClipboard: (fields: string[]) => void; handleRowAction: (row: any, action: 'view' | 'edit' | 'clone' | 'apex') => void; setSelectedRows: (rows: ReadonlySet) => void; } @@ -190,6 +192,7 @@ interface ModalDataTableProps extends SubqueryContext { function ModalDataTable({ isActive, columnKey, + columns, modalTagline, queryResults, selectedRows, @@ -202,6 +205,7 @@ function ModalDataTable({ google_apiKey, google_appId, google_clientId, + onSubqueryFieldReorder, loadMore, openDownloadModal, handleCloseModal, @@ -213,21 +217,28 @@ function ModalDataTable({ const { records, done, totalSize } = queryResults; - const columns = getColumns(columnDefinitions) || []; - const columnKeys = columns?.map((col) => col.key) || null; - const fields = columns.filter((column) => column.key && !NON_DATA_COLUMN_KEYS.has(column.key)).map((column) => column.key); - const rows = records.map((row) => { + const { fields: _fields, rows } = useMemo(() => { + const columnKeys = columns?.map((col) => col.key) || null; + const fields = columns.filter((column) => column.key && !NON_DATA_COLUMN_KEYS.has(column.key)).map((column) => column.key); + const rows = records.map((row) => { + return { + _key: getRowId(row), + _action: handleRowAction, + _record: row, + ...(columnKeys ? flattenRecord(row, columnKeys, false) : row), + }; + }); return { - _key: getRowId(row), - _action: handleRowAction, - _record: row, - ...(columnKeys ? flattenRecord(row, columnKeys, false) : row), + fields, + rows, }; - }); + }, [columns, handleRowAction, records]); - function getColumns(subqueryColumns?: SalesforceQueryColumnDefinition['subqueryColumns']) { - return subqueryColumns?.[columnKey]; - } + const [fields, setFields] = useState(_fields); + + useEffect(() => { + setFields(_fields); + }, [_fields]); const handleContextMenuAction = useCallback( (item: ContextMenuItem, data: ContextMenuActionData) => { @@ -236,6 +247,12 @@ function ModalDataTable({ [fields] ); + const handleColumnReorder = useCallback((columns: string[], columnOrder: number[]) => { + setFields(columns); + onSubqueryFieldReorder && onSubqueryFieldReorder(columnKey, columns, columnOrder); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( <> {isActive && ( @@ -274,7 +291,7 @@ function ModalDataTable({
@@ -321,7 +339,6 @@ function ModalDataTable({ google_clientId={google_clientId} downloadModalOpen fields={fields} - modifiedFields={fields} records={records} onModalClose={handleCloseModal} /> diff --git a/libs/ui/src/lib/data-table/DataTree.tsx b/libs/ui/src/lib/data-table/DataTree.tsx index fca1a450f..1f7022d41 100644 --- a/libs/ui/src/lib/data-table/DataTree.tsx +++ b/libs/ui/src/lib/data-table/DataTree.tsx @@ -24,7 +24,7 @@ export interface DataTreeProps> getRowKey: (row: T) => string; rowAlwaysVisible?: (row: T) => boolean; ignoreRowInSetFilter?: (row: T) => boolean; - onReorderColumns?: (columns: string[]) => void; + onReorderColumns?: (columns: string[], columnOrder: number[]) => void; onSortedAndFilteredRowsChange?: (rows: readonly T[]) => void; } diff --git a/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx b/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx index 386f1abce..66ac001db 100644 --- a/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx +++ b/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx @@ -63,7 +63,8 @@ export interface SalesforceRecordDataTableProps { onSelectionChanged: (rows: any[]) => void; onFilteredRowsChanged: (rows: any[]) => void; /** Fired when query is loaded OR user changes column order */ - onFields: (fields: { allFields: string[]; visibleFields: string[] }) => void; + onFields: (fields: string[], columnOrder: number[]) => void; + onSubqueryFieldReorder: (columnKey: string, fields: string[], columnOrder: number[]) => void; onLoadMoreRecords?: (queryResults: QueryResults) => void; onEdit: (record: any, source: 'ROW_ACTION' | 'RELATED_RECORD_POPOVER') => void; onClone: (record: any, source: 'ROW_ACTION' | 'RELATED_RECORD_POPOVER') => void; @@ -90,6 +91,7 @@ export const SalesforceRecordDataTable: FunctionComponent column.key && !NON_DATA_COLUMN_KEYS.has(column.key)).map((column) => column.key); setColumns(parentColumns); setFields(fields); - onFields({ allFields: fields, visibleFields: fields }); + onFields( + fields, + fields.map((_, i) => i) + ); setSubqueryColumnsMap(subqueryColumns); setRecords(queryResults.queryResults.records); onFilteredRowsChanged(queryResults.queryResults.records); @@ -280,8 +285,8 @@ export const SalesforceRecordDataTable: FunctionComponent { - onFields({ allFields: newFields, visibleFields: newFields }); + const handleColumnReorder = useCallback((columns: string[], columnOrder: number[]) => { + onFields(columns, columnOrder); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -362,6 +367,21 @@ export const SalesforceRecordDataTable: FunctionComponent { + // return { + // ...prevValue, + // [columnKey]: columnOrder.map((idx) => prevValue![columnKey][idx]), + // }; + // }); + } + return records ? ( @@ -421,6 +441,7 @@ export const SalesforceRecordDataTable: FunctionComponent { org: SalesforceOrgUi; isTooling: boolean; columnDefinitions?: MapOf[]>; + onSubqueryFieldReorder?: (columnKey: string, fields: string[], columnOrder: number[]) => void; google_apiKey: string; google_appId: string; google_clientId: string; diff --git a/libs/ui/src/lib/data-table/useDataTable.tsx b/libs/ui/src/lib/data-table/useDataTable.tsx index e2e914c72..6ecc982c5 100644 --- a/libs/ui/src/lib/data-table/useDataTable.tsx +++ b/libs/ui/src/lib/data-table/useDataTable.tsx @@ -44,7 +44,7 @@ export interface UseDataTableProps { getRowKey: (row: any) => string; rowAlwaysVisible?: (row: any) => boolean; ignoreRowInSetFilter?: (row: any) => boolean; - onReorderColumns?: (columns: string[]) => void; + onReorderColumns?: (columns: string[], columnOrder: number[]) => void; onSortedAndFilteredRowsChange?: (rows: readonly any[]) => void; } @@ -111,8 +111,18 @@ export function useDataTable({ }, [_columns]); useNonInitialEffect(() => { - onReorderColumns && onReorderColumns(columns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)).map(({ key }) => key)); - }, [columns, onReorderColumns]); + if (onReorderColumns) { + const newColumns = reorderedColumns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)).map(({ key }) => key); + const remainingIdx = new Set( + reorderedColumns.map((column, i) => (NON_DATA_COLUMN_KEYS.has(column.key) ? -1 : i)).filter((idx) => idx >= 0) + ); + const offset = reorderedColumns.length - newColumns.length; + onReorderColumns( + newColumns, + columnsOrder.filter((idx) => remainingIdx.has(idx)).map((index) => index - offset) + ); + } + }, [reorderedColumns, columnsOrder, onReorderColumns]); useEffect(() => { if (Array.isArray(columns) && columns.length && Array.isArray(data) && data.length) { diff --git a/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx b/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx index 1e7cb455e..6451abf10 100644 --- a/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx +++ b/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx @@ -22,7 +22,6 @@ import FileDownloadGoogle from '../file-download-modal/options/FileDownloadGoogl import Checkbox from '../form/checkbox/Checkbox'; import Input from '../form/input/Input'; import Radio from '../form/radio/Radio'; -import RadioButton from '../form/radio/RadioButton'; import RadioGroup from '../form/radio/RadioGroup'; import Modal from '../modal/Modal'; import { PopoverErrorButton } from '../popover/PopoverErrorButton'; @@ -67,7 +66,6 @@ export interface RecordDownloadModalProps { downloadModalOpen: boolean; columns?: QueryResultsColumn[]; fields: string[]; - modifiedFields?: string[]; subqueryFields?: MapOf; records: Record[]; filteredRecords?: Record[]; @@ -92,7 +90,6 @@ export const RecordDownloadModal: FunctionComponent = downloadModalOpen, columns = [], fields = [], - modifiedFields = [], subqueryFields = {}, records = [], filteredRecords, @@ -114,7 +111,6 @@ export const RecordDownloadModal: FunctionComponent = ); const [includeSubquery, setIncludeSubquery] = useState(true); const [fileName, setFileName] = useState(getFilename(org, ['records'])); - const [columnAreModified, setColumnsAreModified] = useState(false); // If the user changes the filename, we do not want to focus/select the text again or else the user cannot type const [doFocusInput, setDoFocusInput] = useState(true); const inputEl = useRef(null); @@ -154,12 +150,6 @@ export const RecordDownloadModal: FunctionComponent = } }, [isBulkApi]); - useEffect(() => { - if (fields !== modifiedFields && fields.length && modifiedFields.length) { - setColumnsAreModified(fields.some((field, i) => field !== modifiedFields[i])); - } - }, [fields, modifiedFields]); - useEffect(() => { if (!fileName || (fileFormat === 'gdrive' && !isSignedInWithGoogle)) { setInvalidConfig(true); @@ -209,8 +199,7 @@ export const RecordDownloadModal: FunctionComponent = if (errorMessage) { setErrorMessage(null); } - const fieldsToUse = whichFields === 'specified' && modifiedFields?.length ? modifiedFields : fields; - if (fieldsToUse.length === 0) { + if (fields.length === 0) { return; } try { @@ -230,7 +219,7 @@ export const RecordDownloadModal: FunctionComponent = onDownloadFromServer({ fileFormat, fileName: fileNameWithExt, - fields: fieldsToUse, + fields, subqueryFields, whichFields, includeSubquery: includeSubquery && hasSubqueryFields, @@ -250,9 +239,9 @@ export const RecordDownloadModal: FunctionComponent = let data: MapOf = {}; if (includeSubquery && hasSubqueryFields) { - data = getMapOfBaseAndSubqueryRecords(activeRecords, fieldsToUse, subqueryFields); + data = getMapOfBaseAndSubqueryRecords(activeRecords, fields, subqueryFields); } else { - data['records'] = flattenRecords(activeRecords, fieldsToUse); + data['records'] = flattenRecords(activeRecords, fields); } fileData = prepareExcelFile(data); @@ -260,8 +249,8 @@ export const RecordDownloadModal: FunctionComponent = break; } case 'csv': { - const data = flattenRecords(activeRecords, fieldsToUse); - fileData = prepareCsvFile(data, fieldsToUse); + const data = flattenRecords(activeRecords, fields); + fileData = prepareCsvFile(data, fields); mimeType = MIME_TYPES.CSV; break; } @@ -464,28 +453,6 @@ export const RecordDownloadModal: FunctionComponent = /> )} - {fileFormat !== 'json' && columnAreModified && !requireBulkApi && ( - - setWhichFields('specified')} - /> - setWhichFields('all')} - /> - - )}