diff --git a/apps/jetstream-desktop-client/src/app/components/core/AppStateResetOnOrgChange.tsx b/apps/jetstream-desktop-client/src/app/components/core/AppStateResetOnOrgChange.tsx index 8d87dc118..539001a87 100644 --- a/apps/jetstream-desktop-client/src/app/components/core/AppStateResetOnOrgChange.tsx +++ b/apps/jetstream-desktop-client/src/app/components/core/AppStateResetOnOrgChange.tsx @@ -24,6 +24,10 @@ export const AppStateResetOnOrgChange = () => { useResetAtom(fromQueryState.queryFieldsMapState), useResetAtom(fromQueryState.selectedQueryFieldsState), useResetAtom(fromQueryState.selectedSubqueryFieldsState), + useResetAtom(fromQueryState.querySubqueryFiltersState), + useResetAtom(fromQueryState.querySubqueryOrderByState), + useResetAtom(fromQueryState.querySubqueryLimitState), + useResetAtom(fromQueryState.subqueryConfigPanelState), useResetAtom(fromQueryState.filterQueryFieldsState), useResetAtom(fromQueryState.orderByQueryFieldsState), useResetAtom(fromQueryState.queryFiltersState), diff --git a/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx b/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx index 8d87dc118..539001a87 100644 --- a/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx +++ b/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx @@ -24,6 +24,10 @@ export const AppStateResetOnOrgChange = () => { useResetAtom(fromQueryState.queryFieldsMapState), useResetAtom(fromQueryState.selectedQueryFieldsState), useResetAtom(fromQueryState.selectedSubqueryFieldsState), + useResetAtom(fromQueryState.querySubqueryFiltersState), + useResetAtom(fromQueryState.querySubqueryOrderByState), + useResetAtom(fromQueryState.querySubqueryLimitState), + useResetAtom(fromQueryState.subqueryConfigPanelState), useResetAtom(fromQueryState.filterQueryFieldsState), useResetAtom(fromQueryState.orderByQueryFieldsState), useResetAtom(fromQueryState.queryFiltersState), diff --git a/libs/features/query/src/QueryBuilder/QueryBuilder.tsx b/libs/features/query/src/QueryBuilder/QueryBuilder.tsx index 6f1484ff9..904569b3a 100644 --- a/libs/features/query/src/QueryBuilder/QueryBuilder.tsx +++ b/libs/features/query/src/QueryBuilder/QueryBuilder.tsx @@ -43,7 +43,7 @@ import { applicationCookieState, selectedOrgState, soqlQueryFormatOptionsState } import { formatQuery } from '@jetstreamapp/soql-parser-js'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useResetAtom } from 'jotai/utils'; -import { Fragment, useCallback, useRef, useState } from 'react'; +import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import ManualSoql from '../QueryOptions/ManualSoql'; import QueryBuilderAdvancedOptions from '../QueryOptions/QueryBuilderAdvancedOptions'; @@ -61,6 +61,7 @@ import QueryWalkthrough from '../QueryWalkthrough/QueryWalkthrough'; import ExecuteQueryButton from './ExecuteQueryButton'; import QueryBuilderSoqlUpdater from './QueryBuilderSoqlUpdater'; import QueryFieldsComponent from './QueryFields'; +import QuerySubqueryConfigPanel from './QuerySubqueryConfigPanel'; import QuerySubquerySObjects from './QuerySubquerySObjects'; const HEIGHT_BUFFER = 175; @@ -72,6 +73,36 @@ const METADATA_QUERY_ID = 'metadata'; const METADATA_QUERY_TITLE = 'Query Metadata Records'; const METADATA_QUERY_ICON: IconObj = { type: 'standard', icon: 'settings', description: 'Metadata Query' }; +// Tiny atom-connected wrappers so order-by / limit subscriptions stay leaf-scoped — +// a change to queryOrderByState / queryLimit re-renders the wrapper, not the whole QueryBuilder body. +interface ConnectedQueryOrderByProps { + sobject: string; + fields: ListItem[]; + onLoadRelatedFields: (item: ListItem) => Promise; +} + +const ConnectedQueryOrderBy: FunctionComponent = ({ sobject, fields, onLoadRelatedFields }) => { + const [orderByClauses, setOrderByClauses] = useAtom(fromQueryState.queryOrderByState); + return ( + + ); +}; + +const ConnectedQueryLimit: FunctionComponent = () => { + const [limit, setLimit] = useAtom(fromQueryState.queryLimit); + const [limitSkip, setLimitSkip] = useAtom(fromQueryState.queryLimitSkip); + const hasLimitOverride = useAtomValue(fromQueryState.selectQueryLimitHasOverride); + return ( + + ); +}; + export const QueryBuilder = () => { const { trackEvent } = useAmplitude(); const navigate = useNavigate(); @@ -94,6 +125,8 @@ export const QueryBuilder = () => { const setGroupByFields = useSetAtom(fromQueryState.groupByQueryFieldsState); const queryKey = useAtomValue(fromQueryState.selectQueryKeyState); const [queryFilters, setQueryFilters] = useAtom(fromQueryState.queryFiltersState); + const subqueryConfigPanel = useAtomValue(fromQueryState.subqueryConfigPanelState); + const resetSubqueryConfigPanel = useResetAtom(fromQueryState.subqueryConfigPanelState); const resetSelectedQueryFieldsState = useResetAtom(fromQueryState.selectedQueryFieldsState); const resetFilterQueryFieldsState = useResetAtom(fromQueryState.filterQueryFieldsState); @@ -103,6 +136,9 @@ export const QueryBuilder = () => { const resetQueryFieldsMapState = useResetAtom(fromQueryState.queryFieldsMapState); const resetQueryFieldsKey = useResetAtom(fromQueryState.queryFieldsKey); const resetSelectedSubqueryFieldsState = useResetAtom(fromQueryState.selectedSubqueryFieldsState); + const resetQuerySubqueryFiltersState = useResetAtom(fromQueryState.querySubqueryFiltersState); + const resetQuerySubqueryOrderByState = useResetAtom(fromQueryState.querySubqueryOrderByState); + const resetQuerySubqueryLimitState = useResetAtom(fromQueryState.querySubqueryLimitState); const resetQueryFiltersState = useResetAtom(fromQueryState.queryFiltersState); const resetQueryHavingState = useResetAtom(fromQueryState.queryHavingState); const resetFieldFilterFunctions = useResetAtom(fromQueryState.fieldFilterFunctions); @@ -241,6 +277,10 @@ export const QueryBuilder = () => { resetFilterQueryFieldsState(); resetOrderByQueryFieldsState(); resetSelectedSubqueryFieldsState(); + resetQuerySubqueryFiltersState(); + resetQuerySubqueryOrderByState(); + resetQuerySubqueryLimitState(); + resetSubqueryConfigPanel(); resetQueryFiltersState(); resetQueryHavingState(); resetFieldFilterFunctions(); @@ -260,6 +300,10 @@ export const QueryBuilder = () => { resetFilterQueryFieldsState, resetOrderByQueryFieldsState, resetSelectedSubqueryFieldsState, + resetQuerySubqueryFiltersState, + resetQuerySubqueryOrderByState, + resetQuerySubqueryLimitState, + resetSubqueryConfigPanel, resetQueryFiltersState, resetQueryHavingState, resetFieldFilterFunctions, @@ -279,6 +323,14 @@ export const QueryBuilder = () => { resetState(); }, [isTooling, resetState]); + // The subquery config panel is ephemeral UI. Clear it on unmount so it never + // re-opens when the user navigates away and later returns to this page. + useEffect(() => { + return () => { + resetSubqueryConfigPanel(); + }; + }, [resetSubqueryConfigPanel]); + function handleOpenHistory(type: fromQueryHistoryState.QueryHistoryType) { queryHistoryRef.current?.open(type); } @@ -287,6 +339,16 @@ export const QueryBuilder = () => { {showWalkthrough && } + {subqueryConfigPanel && ( + + )} @@ -464,7 +526,7 @@ export const QueryBuilder = () => { title: 'Order By', titleSummaryIfCollapsed: , content: ( - { id: 'limit', title: 'Limit', titleSummaryIfCollapsed: , - content: , + content: , }, { id: 'soql', diff --git a/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx b/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx new file mode 100644 index 000000000..b26b2bba7 --- /dev/null +++ b/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx @@ -0,0 +1,327 @@ +import { css } from '@emotion/react'; +import { logger } from '@jetstream/shared/client-logger'; +import { fetchFields, getListItemsFromFieldWithRelatedItems, sortQueryFields, unFlattenedListItemsById } from '@jetstream/shared/ui-utils'; +import { groupByFlat } from '@jetstream/shared/utils'; +import { ExpressionType, Field, ListItem, QueryFields, QueryOrderByClause, SalesforceOrgUi } from '@jetstream/types'; +import { Panel, Spinner } from '@jetstream/ui'; +import { fromQueryState } from '@jetstream/ui-core'; +import { getSubqueryFieldBaseKey } from '@jetstream/ui-core/shared'; +import { useAtom, useAtomValue } from 'jotai'; +import isEmpty from 'lodash/isEmpty'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import QueryFilter from '../QueryOptions/QueryFilter'; +import QueryLimit from '../QueryOptions/QueryLimit'; +import QueryOrderBy from '../QueryOptions/QueryOrderBy'; + +export interface QuerySubqueryConfigPanelProps { + org: SalesforceOrgUi; + relationshipName: string; + childSObject: string; + isOpen: boolean; + onClose: () => void; +} + +const EMPTY_FILTER: ExpressionType = { + action: 'AND', + rows: [ + { + key: 0, + selected: { + resource: null, + resourceGroup: null, + function: null, + operator: 'eq', + value: '', + }, + }, + ], +}; + +const INITIAL_ORDER_BY: QueryOrderByClause[] = [fromQueryState.initOrderByClause(0)]; + +export const QuerySubqueryConfigPanel: FunctionComponent = ({ + org, + relationshipName, + childSObject, + isOpen, + onClose, +}) => { + const [queryFieldsMap, setQueryFieldsMap] = useAtom(fromQueryState.queryFieldsMapState); + const isTooling = useAtomValue(fromQueryState.isTooling); + const [subqueryFilters, setSubqueryFilters] = useAtom(fromQueryState.querySubqueryFiltersState); + const [subqueryOrderBy, setSubqueryOrderBy] = useAtom(fromQueryState.querySubqueryOrderByState); + const [subqueryLimit, setSubqueryLimit] = useAtom(fromQueryState.querySubqueryLimitState); + + const childBaseKey = useMemo(() => getSubqueryFieldBaseKey(childSObject, relationshipName), [childSObject, relationshipName]); + + const [isFetchingBaseFields, setIsFetchingBaseFields] = useState(false); + + // If the user opens the panel on a child relationship they've never expanded in the field picker, + // queryFieldsMap is empty for this subquery. Lazily populate it using the same fetch path as QueryChildFields. + useEffect(() => { + if (!isOpen) { + return; + } + if (!isEmpty(queryFieldsMap[childBaseKey])) { + return; + } + setIsFetchingBaseFields(true); + const loadingEntry: QueryFields = { + key: childBaseKey, + isPolymorphic: false, + expanded: true, + loading: true, + hasError: false, + filterTerm: '', + sobject: childSObject, + fields: {}, + visibleFields: new Set(), + selectedFields: new Set(), + }; + setQueryFieldsMap((prev) => ({ ...prev, [childBaseKey]: loadingEntry })); + let cancelled = false; + (async () => { + try { + const fetched = await fetchFields(org, loadingEntry, childBaseKey, isTooling); + if (cancelled) { + return; + } + setQueryFieldsMap((prev) => ({ ...prev, [childBaseKey]: { ...fetched, loading: false } })); + } catch (ex) { + logger.warn('[SUBQUERY PANEL] Error fetching base fields', ex); + if (cancelled) { + return; + } + setQueryFieldsMap((prev) => ({ ...prev, [childBaseKey]: { ...loadingEntry, loading: false, hasError: true } })); + } finally { + if (!cancelled) { + setIsFetchingBaseFields(false); + } + } + })(); + return () => { + cancelled = true; + // If the fetch hasn't resolved, drop the loading placeholder so the next + // mount retries instead of short-circuiting on a stale `{ loading: true }` entry. + setQueryFieldsMap((prev) => { + if (prev[childBaseKey]?.loading) { + const next = { ...prev }; + delete next[childBaseKey]; + return next; + } + return prev; + }); + }; + // We intentionally depend only on open + base key; queryFieldsMap is a moving target + // and isTooling is stable for the life of a panel instance. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, childBaseKey]); + + const { filterFields, orderByFields } = useMemo(() => { + const allListItems = Object.values(queryFieldsMap) + .filter((queryField) => queryField.key === childBaseKey || queryField.key.startsWith(childBaseKey)) + .flatMap((item) => { + const path = item.key.slice(childBaseKey.length); + const parentKey = path ? path.slice(0, -1) : ``; + return getListItemsFromFieldWithRelatedItems(sortQueryFields(item.metadata?.fields || []), parentKey); + }); + return { + filterFields: unFlattenedListItemsById( + groupByFlat( + allListItems.filter((item) => item.meta?.filterable), + 'id', + ), + ), + orderByFields: unFlattenedListItemsById( + groupByFlat( + allListItems.filter((item) => item.meta?.sortable), + 'id', + ), + ), + }; + }, [queryFieldsMap, childBaseKey]); + + const filters = subqueryFilters[relationshipName] ?? EMPTY_FILTER; + const orderByClauses = subqueryOrderBy[relationshipName] ?? INITIAL_ORDER_BY; + const limit = subqueryLimit[relationshipName] ?? ''; + + const handleFiltersChange = useCallback( + (next: ExpressionType) => { + setSubqueryFilters((prev) => ({ ...prev, [relationshipName]: next })); + }, + [relationshipName, setSubqueryFilters], + ); + + const handleOrderByChange = useCallback( + (next: QueryOrderByClause[]) => { + setSubqueryOrderBy((prev) => ({ ...prev, [relationshipName]: next })); + }, + [relationshipName, setSubqueryOrderBy], + ); + + const handleLimitChange = useCallback( + (next: string) => { + setSubqueryLimit((prev) => ({ ...prev, [relationshipName]: next })); + }, + [relationshipName, setSubqueryLimit], + ); + + const handleClearAll = useCallback(() => { + const omit = (prev: Record) => { + if (!(relationshipName in prev)) { + return prev; + } + const next = { ...prev }; + delete next[relationshipName]; + return next; + }; + setSubqueryFilters((prev) => omit(prev) as typeof prev); + setSubqueryOrderBy((prev) => omit(prev) as typeof prev); + setSubqueryLimit((prev) => omit(prev) as typeof prev); + }, [relationshipName, setSubqueryFilters, setSubqueryOrderBy, setSubqueryLimit]); + + const summary = useAtomValue(fromQueryState.subqueryOptionsSummaryState)[relationshipName]; + const hasAnyConfigured = !!summary && (summary.filterCount > 0 || summary.hasOrderBy || !!summary.limit); + + // Describe-on-demand drill-in for related fields in filter/orderBy rows. + // Also seeds queryFieldsMap so that a later restore of a saved query can resolve + // filter/orderBy fields that live behind a relationship the user never expanded + // in the field picker. + const loadRelatedFields = useCallback( + async (item: ListItem): Promise => { + try { + const field = item.meta as Field; + if (!Array.isArray(field.referenceTo) || field.referenceTo.length <= 0) { + return []; + } + const nestedKey = `${childBaseKey}${item.id}.`; + const initialEntry: QueryFields = { + key: nestedKey, + isPolymorphic: field.referenceTo.length > 1, + expanded: true, + loading: false, + hasError: false, + filterTerm: '', + sobject: field.referenceTo[0], + fields: {}, + visibleFields: new Set(), + selectedFields: new Set(), + }; + const fetched = await fetchFields(org, initialEntry, nestedKey, isTooling); + setQueryFieldsMap((prev) => (prev[nestedKey] ? prev : { ...prev, [nestedKey]: { ...fetched, loading: false } })); + const sorted = sortQueryFields(fetched.metadata?.fields || []); + return getListItemsFromFieldWithRelatedItems(sorted, item.id); + } catch (ex) { + logger.warn('Error fetching related fields for subquery panel', ex); + return []; + } + }, + [org, isTooling, childBaseKey, setQueryFieldsMap], + ); + + const loadFilterRelatedFields = useCallback( + async (item: ListItem) => { + const items = await loadRelatedFields(item); + return items.filter((child) => child.meta?.filterable); + }, + [loadRelatedFields], + ); + + const loadOrderByRelatedFields = useCallback( + async (item: ListItem) => { + const items = await loadRelatedFields(item); + return items.filter((child) => child.meta?.sortable); + }, + [loadRelatedFields], + ); + + const hasChildMetadata = !isEmpty(queryFieldsMap[childBaseKey]) && !queryFieldsMap[childBaseKey]?.loading; + + return ( + +
+
+ {isFetchingBaseFields && } + {!hasChildMetadata && !isFetchingBaseFields && ( +

Loading fields…

+ )} + {hasChildMetadata && ( + +
+

Filters

+ +
+ +
+

Order By

+ +
+ +
+

Limit

+ +
+
+ )} +
+
+ + +
+
+
+ ); +}; + +export default QuerySubqueryConfigPanel; diff --git a/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx b/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx index 576a511ef..296b46931 100644 --- a/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx +++ b/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx @@ -1,10 +1,11 @@ +import { css } from '@emotion/react'; import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; import { multiWordObjectFilter, pluralizeFromNumber } from '@jetstream/shared/utils'; import { ChildRelationship, QueryFieldWithPolymorphic, SalesforceOrgUi } from '@jetstream/types'; -import { Accordion, Badge, DesertIllustration, EmptyState, Grid, GridCol, SearchInput } from '@jetstream/ui'; +import { Accordion, Badge, DesertIllustration, EmptyState, Grid, GridCol, Icon, SearchInput } from '@jetstream/ui'; import { fromQueryState } from '@jetstream/ui-core'; -import { useAtomValue } from 'jotai'; -import { Fragment, FunctionComponent, ReactNode, useState } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { Fragment, FunctionComponent, ReactNode, useRef, useState } from 'react'; import QueryChildFields from './QueryChildFields'; export interface QuerySubquerySObjectsProps { @@ -23,13 +24,16 @@ export const QuerySubquerySObjects: FunctionComponent { const [visibleChildRelationships, setVisibleChildRelationships] = useState(childRelationships); - const [childRelationshipContent, setChildRelationshipContent] = useState>({}); + const childRelationshipContentRef = useRef>({}); const [textFilter, setTextFilter] = useState(''); const selectedFieldState = useAtomValue(fromQueryState.selectedSubqueryFieldsState); + const subquerySummary = useAtomValue(fromQueryState.subqueryOptionsSummaryState); + const setConfigPanel = useSetAtom(fromQueryState.subqueryConfigPanelState); useNonInitialEffect(() => { setVisibleChildRelationships(childRelationships); setTextFilter(''); + childRelationshipContentRef.current = {}; }, [childRelationships]); useNonInitialEffect(() => { @@ -42,30 +46,90 @@ export const QuerySubquerySObjects: FunctionComponent { - let content: ReactNode | ChildRelationship; if (!childRelationship.relationshipName) { return; } - if (childRelationshipContent[childRelationship.relationshipName]) { - content = childRelationshipContent[childRelationship.relationshipName]; - } else { - content = ( + // The "Configure" header is re-rendered each time because it depends on the latest + // summary atom; the (heavier) field picker below is memoized in childRelationshipContentRef. + const relationshipName = childRelationship.relationshipName; + const summary = subquerySummary[relationshipName]; + let fieldPicker = childRelationshipContentRef.current[relationshipName]; + if (!fieldPicker) { + fieldPicker = ( onSelectionChanged(childRelationship.relationshipName!, fields)} + parentRelationshipName={relationshipName} + onSelectionChanged={(fields: QueryFieldWithPolymorphic[]) => onSelectionChanged(relationshipName, fields)} /> ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setTimeout(() => setChildRelationshipContent({ ...childRelationshipContent, [childRelationship.relationshipName!]: content })); + childRelationshipContentRef.current[relationshipName] = fieldPicker; } - return content; + return ( + +
+ + + + + {summary?.filterCount ? ( + + + {summary.filterCount} {pluralizeFromNumber('filter', summary.filterCount)} + + ) : null} + {summary?.hasOrderBy ? ( + + + sorted + + ) : null} + {summary?.limit ? ( + + limit {summary.limit} + + ) : null} + +
+ {fieldPicker} +
+ ); }; } @@ -74,14 +138,42 @@ export const QuerySubquerySObjects: FunctionComponent - {queryFields.length} {pluralizeFromNumber('field', queryFields.length)} selected - - ); + const summary = subquerySummary[childRelationship.relationshipName]; + + if (!Array.isArray(queryFields) && !summary) { + return; } - return; + + const summaryText = buildSummaryParts(summary).join(' · '); + + return ( + + {Array.isArray(queryFields) && ( + + {queryFields.length} {pluralizeFromNumber('field', queryFields.length)} selected + + )} + {summaryText && ( + + + + )} + + ); } return ( diff --git a/libs/features/query/src/QueryOptions/QueryLimit.tsx b/libs/features/query/src/QueryOptions/QueryLimit.tsx index 4a5a88a5e..341432917 100644 --- a/libs/features/query/src/QueryOptions/QueryLimit.tsx +++ b/libs/features/query/src/QueryOptions/QueryLimit.tsx @@ -1,76 +1,72 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; import { REGEX } from '@jetstream/shared/utils'; import { Input } from '@jetstream/ui'; -import { fromQueryState } from '@jetstream/ui-core'; -import { useAtom, useAtomValue } from 'jotai'; -import React, { FunctionComponent, useEffect, useState } from 'react'; +import React, { FunctionComponent } from 'react'; -export interface QueryLimitProps {} +export interface QueryLimitProps { + limit: string; + setLimit: (value: string) => void; + /** Omit to hide the Skip (OFFSET) input — e.g. for subqueries. */ + limitSkip?: string; + setLimitSkip?: (value: string) => void; + /** When true, the limit input is forced to "1" and disabled (tooling override). */ + hasLimitOverride?: boolean; + /** Controls the id attribute of the inputs so multiple instances can coexist. */ + idPrefix?: string; +} function sanitize(value: string) { return value.replace(REGEX.NOT_UNSIGNED_NUMERIC, ''); } -export const QueryLimit: FunctionComponent = React.memo(() => { - const hasLimitOverride = useAtomValue(fromQueryState.selectQueryLimitHasOverride); - const [queryLimitState, setQueryLimitState] = useAtom(fromQueryState.queryLimit); - const [queryLimitSkipState, setQueryLimitSkipState] = useAtom(fromQueryState.queryLimitSkip); - - // const [queryLimitPriorValue, setQueryLimitPriorValue] = useState(queryLimitState); - const [queryLimit, setQueryLimit] = useState(queryLimitState); - const [queryLimitSkip, setQueryLimitSkip] = useState(queryLimitSkipState); - - // If local state changes to something different, update globally - // ignore limit change if in override mode - useEffect(() => { - if (queryLimitState !== queryLimit) { - setQueryLimitState(queryLimit); - } - - if (queryLimitSkip !== queryLimitSkipState) { - setQueryLimitSkipState(queryLimitSkip); - } - }, [hasLimitOverride, queryLimit, queryLimitSkip, queryLimitSkipState, queryLimitState, setQueryLimitSkipState, setQueryLimitState]); +export const QueryLimit: FunctionComponent = React.memo( + ({ limit, setLimit, limitSkip, setLimitSkip, hasLimitOverride, idPrefix = 'query-limit' }) => { + useNonInitialEffect(() => { + if (hasLimitOverride) { + setLimit('1'); + } else { + setLimit(''); + } + // Only react to override toggles — intentionally omit setLimit to avoid re-firing on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasLimitOverride]); - useNonInitialEffect(() => { - if (hasLimitOverride) { - setQueryLimit('1'); - } else { - setQueryLimit(''); + function handleQueryLimitChange(event: React.ChangeEvent) { + setLimit(sanitize(event.target.value)); } - }, [hasLimitOverride, setQueryLimitState]); - function handleQueryLimitChange(event: React.ChangeEvent) { - const value = sanitize(event.target.value); - setQueryLimit(value); - } + const limitInputId = `${idPrefix}`; + const skipInputId = `${idPrefix}-skip`; + const showSkip = typeof limitSkip === 'string' && typeof setLimitSkip === 'function'; - return ( -
- - - - - setQueryLimitSkip(sanitize(event.target.value))} - /> - -
- ); -}); + return ( +
+ + + + {showSkip && ( + + setLimitSkip?.(sanitize(event.target.value))} + /> + + )} +
+ ); + }, +); export default QueryLimit; diff --git a/libs/features/query/src/QueryOptions/QueryOrderBy.tsx b/libs/features/query/src/QueryOptions/QueryOrderBy.tsx index 8a43f3ac8..70df99651 100644 --- a/libs/features/query/src/QueryOptions/QueryOrderBy.tsx +++ b/libs/features/query/src/QueryOptions/QueryOrderBy.tsx @@ -1,14 +1,14 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { AscDesc, FirstLast, ListItem, QueryOrderByClause } from '@jetstream/types'; import { Icon } from '@jetstream/ui'; import { fromQueryState } from '@jetstream/ui-core'; -import { useAtom } from 'jotai'; import React, { Fragment, FunctionComponent, useState } from 'react'; import QueryOrderByRow from './QueryOrderByRow'; export interface QueryOrderByContainerProps { sobject: string; fields: ListItem[]; + orderByClauses: QueryOrderByClause[]; + setOrderByClauses: (clauses: QueryOrderByClause[]) => void; onLoadRelatedFields: (item: ListItem) => Promise; } @@ -24,8 +24,7 @@ const nulls: ListItem[] = [ ]; export const QueryOrderByContainer: FunctionComponent = React.memo( - ({ sobject, fields, onLoadRelatedFields }) => { - const [orderByClauses, setOrderByClauses] = useAtom(fromQueryState.queryOrderByState); + ({ sobject, fields, orderByClauses, setOrderByClauses, onLoadRelatedFields }) => { const [nextKey, setNextKey] = useState(1); function handleUpdate(orderby: QueryOrderByClause) { diff --git a/libs/features/query/src/QueryOptions/QueryResetButton.tsx b/libs/features/query/src/QueryOptions/QueryResetButton.tsx index d9414fc68..accc83ac6 100644 --- a/libs/features/query/src/QueryOptions/QueryResetButton.tsx +++ b/libs/features/query/src/QueryOptions/QueryResetButton.tsx @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { Icon } from '@jetstream/ui'; import { fromQueryState, useAmplitude } from '@jetstream/ui-core'; @@ -22,6 +20,10 @@ export const QueryResetButton: FunctionComponent = ({ cla useResetAtom(fromQueryState.queryFieldsMapState), useResetAtom(fromQueryState.selectedQueryFieldsState), useResetAtom(fromQueryState.selectedSubqueryFieldsState), + useResetAtom(fromQueryState.querySubqueryFiltersState), + useResetAtom(fromQueryState.querySubqueryOrderByState), + useResetAtom(fromQueryState.querySubqueryLimitState), + useResetAtom(fromQueryState.subqueryConfigPanelState), useResetAtom(fromQueryState.filterQueryFieldsState), useResetAtom(fromQueryState.orderByQueryFieldsState), useResetAtom(fromQueryState.queryFiltersState), diff --git a/libs/shared/ui-core-shared/src/__tests__/query-soql-utils.spec.ts b/libs/shared/ui-core-shared/src/__tests__/query-soql-utils.spec.ts index d81d4022c..1521fbfec 100644 --- a/libs/shared/ui-core-shared/src/__tests__/query-soql-utils.spec.ts +++ b/libs/shared/ui-core-shared/src/__tests__/query-soql-utils.spec.ts @@ -1,7 +1,13 @@ import { FieldType as QueryFieldType, parseQuery } from '@jetstreamapp/soql-parser-js'; import { SoqlMetadataTree, __TEST_EXPORTS__ } from '../query-soql-utils'; -const { getParsableFields, getParsableFieldsFromFilter, findRequiredRelationships, getLowercaseFieldMapWithFullPath } = __TEST_EXPORTS__; +const { + getParsableFields, + getParsableFieldsFromFilter, + getParsableFieldsFromOrderBy, + findRequiredRelationships, + getLowercaseFieldMapWithFullPath, +} = __TEST_EXPORTS__; describe('getParsableFields', () => { it('should return an empty array when given an empty array of fields', () => { @@ -92,6 +98,35 @@ describe('getParsableFieldsFromFilter', () => { }); }); +describe('getParsableFieldsFromOrderBy', () => { + it('returns an empty array when orderBy is null/undefined', () => { + expect(getParsableFieldsFromOrderBy(null)).toEqual([]); + expect(getParsableFieldsFromOrderBy(undefined)).toEqual([]); + }); + + it('extracts fields from a single OrderBy clause', () => { + const orderBy = parseQuery(`SELECT Id FROM Account ORDER BY Name DESC`).orderBy; + expect(getParsableFieldsFromOrderBy(orderBy)).toEqual(['Name']); + }); + + it('extracts fields from multiple OrderBy clauses', () => { + const orderBy = parseQuery(`SELECT Id FROM Account ORDER BY Name ASC, CreatedDate DESC`).orderBy; + expect(getParsableFieldsFromOrderBy(orderBy)).toEqual(['Name', 'CreatedDate']); + }); +}); + +describe('getParsableFields — subquery with WHERE and ORDER BY', () => { + it('collects fields from subquery SELECT + WHERE + ORDER BY', () => { + const query = parseQuery( + `SELECT Id, (SELECT Id, Name FROM Contacts WHERE Email != null AND Account.Industry = 'media' ORDER BY CreatedDate DESC LIMIT 5) FROM Account`, + ); + const result = getParsableFields(query.fields || []); + expect(result.subqueries['Contacts']).toEqual(expect.arrayContaining(['id', 'name', 'email', 'account.industry', 'createddate'])); + // Should not leak subquery fields into the top-level fields list + expect(result.fields).not.toContain('email'); + }); +}); + describe('findRequiredRelationships', () => { it('should return an empty array when given an empty array of fields', () => { const fields: string[] = []; diff --git a/libs/shared/ui-core-shared/src/query-soql-utils.ts b/libs/shared/ui-core-shared/src/query-soql-utils.ts index cb6000ad5..db80f0728 100644 --- a/libs/shared/ui-core-shared/src/query-soql-utils.ts +++ b/libs/shared/ui-core-shared/src/query-soql-utils.ts @@ -4,6 +4,7 @@ import { getFieldKey } from '@jetstream/shared/ui-utils'; import { orderValues } from '@jetstream/shared/utils'; import { DescribeGlobalSObjectResult, DescribeSObjectResult, Field, Maybe, SalesforceOrgUi } from '@jetstream/types'; import { + OrderByClause, Query, FieldType as QueryFieldType, WhereClause, @@ -151,19 +152,19 @@ function getFieldsFromAllPartsOfQuery(query: Query): ParsableFields { const parsableFields = getParsableFields(query.fields || []); parsableFields.fields = parsableFields.fields.concat(getParsableFieldsFromFilter(query.where)); - - if (query.orderBy) { - const orderBy = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy]; - orderBy.forEach((orderBy) => { - if (isOrderByField(orderBy)) { - parsableFields.fields.push(orderBy.field); - } - }); - } + parsableFields.fields = parsableFields.fields.concat(getParsableFieldsFromOrderBy(query.orderBy)); return parsableFields; } +function getParsableFieldsFromOrderBy(orderBy: Maybe): string[] { + if (!orderBy) { + return []; + } + const clauses = Array.isArray(orderBy) ? orderBy : [orderBy]; + return clauses.filter(isOrderByField).map((clause) => clause.field); +} + /** * Extract and sort all fields from a parsed query * recursive for subqueries @@ -190,7 +191,12 @@ function getParsableFields(fields: QueryFieldType[]): ParsableFields { output.fields.push(`${firstCondition.objectType}${TYPEOF_SEPARATOR}${field.field}.${typeofField}`), ); } else if (field.type === 'FieldSubquery') { - output.subqueries[field.subquery.relationshipName] = getParsableFields(field.subquery.fields || []).fields; + const subqueryFields = getParsableFields(field.subquery.fields || []).fields; + const subqueryFilterFields = getParsableFieldsFromFilter(field.subquery.where).map((fieldName) => fieldName.toLowerCase()); + const subqueryOrderByFields = getParsableFieldsFromOrderBy(field.subquery.orderBy).map((fieldName) => fieldName.toLowerCase()); + output.subqueries[field.subquery.relationshipName] = orderValues( + Array.from(new Set([...subqueryFields, ...subqueryFilterFields, ...subqueryOrderByFields])), + ); } return output; }, @@ -396,6 +402,7 @@ export const __TEST_EXPORTS__ = { getFieldsFromAllPartsOfQuery, getParsableFields, getParsableFieldsFromFilter, + getParsableFieldsFromOrderBy, fetchAllMetadata, findRequiredRelationships, fetchRecursiveMetadata, diff --git a/libs/shared/ui-core/src/query/RestoreQuery/__tests__/query-restore-utils.spec.ts b/libs/shared/ui-core/src/query/RestoreQuery/__tests__/query-restore-utils.spec.ts new file mode 100644 index 000000000..718c72200 --- /dev/null +++ b/libs/shared/ui-core/src/query/RestoreQuery/__tests__/query-restore-utils.spec.ts @@ -0,0 +1,220 @@ +import { DescribeSObjectResult, Field, FieldWrapper, QueryFields } from '@jetstream/types'; +import { getSubqueryFieldBaseKey, SoqlFetchMetadataOutput } from '@jetstream/ui-core/shared'; +import { parseQuery, Query } from '@jetstreamapp/soql-parser-js'; +import { describe, expect, it } from 'vitest'; +import { __TEST_EXPORTS__ } from '../query-restore-utils'; + +const { processSubqueryOptions, getFieldWrapperPathForSubquery } = __TEST_EXPORTS__; + +function makeField(overrides: Partial): Field { + return { + name: 'Id', + label: 'Id', + type: 'id', + filterable: true, + sortable: true, + referenceTo: [], + ...overrides, + } as Field; +} + +function makeFieldWrapper(field: Field): FieldWrapper { + return { + name: field.name, + label: field.label, + type: field.type, + sobject: '', + filterText: `${field.name} ${field.label}`, + metadata: field, + } as FieldWrapper; +} + +function makeQueryFields(key: string, fields: Field[]): QueryFields { + return { + key, + isPolymorphic: false, + expanded: true, + loading: false, + hasError: false, + filterTerm: '', + sobject: '', + fields: fields.reduce((acc: Record, field) => { + acc[field.name] = makeFieldWrapper(field); + return acc; + }, {}), + visibleFields: new Set(fields.map((f) => f.name)), + selectedFields: new Set(), + metadata: { fields } as DescribeSObjectResult, + } as QueryFields; +} + +function makeMetadataOutput(relationshipName: string, childSObjectName: string): SoqlFetchMetadataOutput { + return { + sobjectMetadata: [], + selectedSobjectMetadata: { global: {} as any, sobject: {} as any }, + metadata: {}, + lowercaseFieldMap: {}, + childMetadata: { + [relationshipName]: { + objectMetadata: { name: childSObjectName } as DescribeSObjectResult, + metadataTree: {}, + lowercaseFieldMap: {}, + }, + }, + }; +} + +describe('getFieldWrapperPathForSubquery', () => { + it('only returns entries under the provided child base key', () => { + const childBaseKey = getSubqueryFieldBaseKey('Contact', 'Contacts'); + const otherBaseKey = 'Account|'; + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' }), makeField({ name: 'Name' })]), + [otherBaseKey]: makeQueryFields(otherBaseKey, [makeField({ name: 'TopLevelField' })]), + }; + + const result = getFieldWrapperPathForSubquery(queryFields, childBaseKey); + + expect(Object.keys(result).sort()).toEqual(['email', 'name']); + expect(result['email'].fieldKey).toBe('Email'); + }); + + it('prefixes field keys with the dotted relationship path for nested child entries', () => { + const childBaseKey = getSubqueryFieldBaseKey('Contact', 'Contacts'); + const nestedKey = `${childBaseKey}Account.`; + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' })]), + [nestedKey]: makeQueryFields(nestedKey, [makeField({ name: 'Industry' })]), + }; + + const result = getFieldWrapperPathForSubquery(queryFields, childBaseKey); + + expect(result['email'].fieldKey).toBe('Email'); + expect(result['account.industry'].fieldKey).toBe('Account.Industry'); + expect(result['account.industry'].parentKey).toBe(nestedKey); + }); +}); + +describe('processSubqueryOptions', () => { + function runProcess(soql: string, childBaseKey: string, queryFields: Record, metadata: SoqlFetchMetadataOutput) { + const stateItems: any = { queryFieldsMapState: queryFields, missingMisc: [] }; + const query: Query = parseQuery(soql); + processSubqueryOptions(stateItems, query, metadata); + return { stateItems, query }; + } + + it('restores simple filter, orderBy, and limit on a flat subquery', () => { + const childBaseKey = getSubqueryFieldBaseKey('Contact', 'Contacts'); + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' }), makeField({ name: 'CreatedDate', type: 'datetime' })]), + }; + const metadata = makeMetadataOutput('Contacts', 'Contact'); + + const { stateItems } = runProcess( + `SELECT Id, (SELECT Id FROM Contacts WHERE Email != null ORDER BY CreatedDate DESC LIMIT 5) FROM Account`, + childBaseKey, + queryFields, + metadata, + ); + + expect(stateItems.querySubqueryFiltersState.Contacts).toEqual( + expect.objectContaining({ + action: 'AND', + rows: expect.arrayContaining([ + expect.objectContaining({ selected: expect.objectContaining({ resource: 'Email', operator: 'isNotNull' }) }), + ]), + }), + ); + expect(stateItems.querySubqueryOrderByState.Contacts).toEqual([expect.objectContaining({ field: 'CreatedDate', order: 'DESC' })]); + expect(stateItems.querySubqueryLimitState.Contacts).toBe('5'); + expect(stateItems.missingMisc).toEqual([]); + }); + + it('restores a subquery filter that references a relationship field (dotted path)', () => { + const childBaseKey = getSubqueryFieldBaseKey('Contact', 'Contacts'); + const nestedKey = `${childBaseKey}Account.`; + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' })]), + [nestedKey]: makeQueryFields(nestedKey, [makeField({ name: 'Industry', type: 'picklist' })]), + }; + const metadata = makeMetadataOutput('Contacts', 'Contact'); + + const { stateItems } = runProcess( + `SELECT Id, (SELECT Id FROM Contacts WHERE Account.Industry = 'media') FROM Account`, + childBaseKey, + queryFields, + metadata, + ); + + expect(stateItems.querySubqueryFiltersState.Contacts.rows[0].selected).toEqual( + expect.objectContaining({ + resource: 'Account.Industry', + resourceGroup: nestedKey, + value: 'media', + }), + ); + }); + + it('detects OR as the root filter operator', () => { + const childBaseKey = getSubqueryFieldBaseKey('Contact', 'Contacts'); + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' }), makeField({ name: 'Phone' })]), + }; + const metadata = makeMetadataOutput('Contacts', 'Contact'); + + const { stateItems } = runProcess( + `SELECT Id, (SELECT Id FROM Contacts WHERE Email != null OR Phone != null) FROM Account`, + childBaseKey, + queryFields, + metadata, + ); + + expect(stateItems.querySubqueryFiltersState.Contacts.action).toBe('OR'); + expect(stateItems.querySubqueryFiltersState.Contacts.rows).toHaveLength(2); + }); + + it('records missing filter fields under missingMisc with the Subquery prefix', () => { + const childBaseKey = getSubqueryFieldBaseKey('Contact', 'Contacts'); + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' })]), + }; + const metadata = makeMetadataOutput('Contacts', 'Contact'); + + const { stateItems } = runProcess( + `SELECT Id, (SELECT Id FROM Contacts WHERE NonExistentField__c = 'x' ORDER BY MissingSortField__c ASC) FROM Account`, + childBaseKey, + queryFields, + metadata, + ); + + // WHERE error + expect(stateItems.missingMisc.some((msg: string) => msg.includes("Subquery 'Contacts'"))).toBe(true); + // No entry should be emitted when all filter rows failed + expect(stateItems.querySubqueryFiltersState.Contacts).toBeUndefined(); + // OrderBy error message + expect(stateItems.missingMisc.some((msg: string) => msg.includes('MissingSortField__c'))).toBe(true); + expect(stateItems.querySubqueryOrderByState.Contacts).toBeUndefined(); + }); + + it('matches parsed relationshipName to childMetadata key case-insensitively and writes state under the canonical name', () => { + const canonicalRelationshipName = 'Contacts'; + const childBaseKey = getSubqueryFieldBaseKey('Contact', canonicalRelationshipName); + const queryFields = { + [childBaseKey]: makeQueryFields(childBaseKey, [makeField({ name: 'Email' })]), + }; + const metadata = makeMetadataOutput(canonicalRelationshipName, 'Contact'); + + // user-typed SOQL with different casing on the relationship name + const { stateItems } = runProcess( + `SELECT Id, (SELECT Id FROM contacts WHERE Email != null LIMIT 3) FROM Account`, + childBaseKey, + queryFields, + metadata, + ); + + expect(stateItems.querySubqueryFiltersState[canonicalRelationshipName]).toBeDefined(); + expect(stateItems.querySubqueryLimitState[canonicalRelationshipName]).toBe('3'); + // Should NOT write under the lowercase key + expect(stateItems.querySubqueryFiltersState['contacts']).toBeUndefined(); + }); +}); diff --git a/libs/shared/ui-core/src/query/RestoreQuery/query-restore-utils.ts b/libs/shared/ui-core/src/query/RestoreQuery/query-restore-utils.ts index f382f5017..df55904b7 100644 --- a/libs/shared/ui-core/src/query/RestoreQuery/query-restore-utils.ts +++ b/libs/shared/ui-core/src/query/RestoreQuery/query-restore-utils.ts @@ -74,6 +74,9 @@ interface QueryRestoreStateItems extends QueryRestoreErrors { queryFieldsMapState: Record; selectedQueryFieldsState: QueryFieldWithPolymorphic[]; selectedSubqueryFieldsState: Record; + querySubqueryFiltersState: Record; + querySubqueryOrderByState: Record; + querySubqueryLimitState: Record; fieldFilterFunctions: fromQueryState.FieldFilterFunction[]; filterQueryFieldsState: ListItem[]; orderByQueryFieldsState: ListItem[]; @@ -182,6 +185,7 @@ async function queryRestoreBuildState(org: SalesforceOrgUi, query: Query, data: processHavingClause(outputStateItems, query, fieldWrapperWithParentKey); processOrderBy(outputStateItems, query, fieldWrapperWithParentKey); processLimit(outputStateItems, query); + processSubqueryOptions(outputStateItems, query, data); return outputStateItems as QueryRestoreStateItems; } @@ -534,6 +538,126 @@ function processLimit(stateItems: Partial, query: Query) } } +/** + * Walk every FieldSubquery in the query and populate per-relationship filter / orderBy / limit state. + * Restores SOQL features that were previously silently dropped during restore. + */ +function processSubqueryOptions(stateItems: Partial, query: Query, data: SoqlFetchMetadataOutput) { + stateItems.querySubqueryFiltersState = {}; + stateItems.querySubqueryOrderByState = {}; + stateItems.querySubqueryLimitState = {}; + + const subqueryFields = (query.fields || []).filter((field): field is FieldSubquery => field.type === 'FieldSubquery'); + if (subqueryFields.length === 0) { + return; + } + const childMetadataByLowerName = Object.keys(data.childMetadata).reduce((acc: Record, name) => { + acc[name.toLowerCase()] = { canonicalName: name }; + return acc; + }, {}); + const queryFieldsMap = stateItems.queryFieldsMapState || {}; + + subqueryFields.forEach((subqueryField) => { + const { subquery } = subqueryField; + const matchedChild = childMetadataByLowerName[subquery.relationshipName.toLowerCase()]; + if (!matchedChild) { + return; + } + const relationshipName = matchedChild.canonicalName; + const childMeta = data.childMetadata[relationshipName]; + const childBaseKey = getSubqueryFieldBaseKey(childMeta.objectMetadata.name, relationshipName); + const fieldWrapperForSubquery = getFieldWrapperPathForSubquery(queryFieldsMap, childBaseKey); + + if (subquery.where) { + const missingForSubquery: string[] = []; + const rows = flattenWhereClause(missingForSubquery, fieldWrapperForSubquery, subquery.where, 0); + if (rows.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + stateItems.querySubqueryFiltersState![relationshipName] = { + action: isWhereClauseWithRightCondition(subquery.where) && subquery.where.operator === 'OR' ? 'OR' : 'AND', + rows, + }; + } + if (missingForSubquery.length > 0) { + stateItems.missingMisc = stateItems.missingMisc || []; + missingForSubquery.forEach((message) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + stateItems.missingMisc!.push(`Subquery '${relationshipName}': ${message}`); + }); + } + } + + if (subquery.orderBy) { + const orderByClauses = (Array.isArray(subquery.orderBy) ? subquery.orderBy : [subquery.orderBy]) as QueryOrderByClause[]; + const restoredOrderBys = orderByClauses + .map((orderBy, i) => { + if (!isOrderByField(orderBy)) { + return null; + } + const foundField = fieldWrapperForSubquery[orderBy.field.toLowerCase()]; + if (!foundField) { + stateItems.missingMisc = stateItems.missingMisc || []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + stateItems.missingMisc!.push(`Subquery '${relationshipName}': Order By ${orderBy.field} was not found`); + return null; + } + const { fieldMetadata, fieldKey, parentKey } = foundField; + const [base, path] = parentKey.split('|'); + const groupLabel = path ? path.substring(0, path.length - 1) : base; + if (!fieldMetadata) { + return null; + } + return { + key: i, + field: fieldKey, + fieldLabel: `${groupLabel} - ${fieldMetadata.label} (${fieldMetadata.name})`, + order: orderBy.order, + nulls: orderBy.nulls || null, + } as QueryOrderByClause; + }) + .filter((orderBy): orderBy is QueryOrderByClause => !!orderBy); + + if (restoredOrderBys.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + stateItems.querySubqueryOrderByState![relationshipName] = restoredOrderBys; + } + } + + if (subquery.limit) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + stateItems.querySubqueryLimitState![relationshipName] = `${subquery.limit}`; + } + }); +} + +/** + * Sibling of getFieldWrapperPath, scoped to a single subquery's base key. + * Walks every queryFieldsMap entry under `${childBaseKey}...` and returns a + * lowercase-field → FieldWrapperWithParentKey lookup usable by flattenWhereClause. + */ +function getFieldWrapperPathForSubquery( + queryFields: Record, + childBaseKey: string, +): Record { + return Object.keys(queryFields) + .filter((key) => key.startsWith(childBaseKey)) + .reduce((output: Record, key) => { + const queryField = queryFields[key]; + // Keys for subqueries look like `${childSobject}~${relationshipName}|{optional.path.}`. + // The "path" portion (after the pipe) is what should prefix each field key, + // matching how soql-parser-js emits dotted field paths in subquery WHERE/ORDER BY. + const fieldPath = key.slice(childBaseKey.length); + Object.keys(queryField.fields).forEach((fieldName) => { + output[`${fieldPath}${fieldName}`.toLowerCase()] = { + parentKey: queryField.key, + fieldKey: `${fieldPath}${fieldName}`, + fieldMetadata: queryField.fields[fieldName], + }; + }); + return output; + }, {}); +} + /** * Attempt to find each field in query and mark as selected * This is called for base query and each subquery individually @@ -728,3 +852,8 @@ function getFieldWrapperPath(queryFields: Record): Record>({} export const selectedQueryFieldsState = atomWithReset([]); export const selectedSubqueryFieldsState = atomWithReset>({}); +export const querySubqueryFiltersState = atomWithReset>({}); +export const querySubqueryOrderByState = atomWithReset>({}); +export const querySubqueryLimitState = atomWithReset>({}); + +/** Drives the page-level subquery config side panel. null = closed. */ +export const subqueryConfigPanelState = atomWithReset<{ relationshipName: string; childSObject: string } | null>(null); + +function orderByClausesToSoql(orderByClauses: QueryOrderByClause[]): OrderByClause[] { + return orderByClauses + .filter((orderBy) => !!orderBy.field) + .map( + (orderBy): OrderByFieldClause => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + field: orderBy.field!, + nulls: orderBy.nulls || undefined, + order: orderBy.order, + }), + ); +} + export const selectQueryField = atom((get) => { const filterFieldFns = get(fieldFilterFunctions).reduce((acc: Record, item) => { if (!!item.selectedField && !!item.selectedFunction) { @@ -43,15 +64,28 @@ export const selectQueryField = atom((get) => { }, {}); let fields = convertFieldWithPolymorphicToQueryFields(get(selectedQueryFieldsState), filterFieldFns); const fieldsByChildRelName = get(selectedSubqueryFieldsState); + const subqueryFilters = get(querySubqueryFiltersState); + const subqueryOrderBys = get(querySubqueryOrderByState); + const subqueryLimits = get(querySubqueryLimitState); // Concat subquery fields fields = fields.concat( orderValues(Object.keys(fieldsByChildRelName)) // remove subquery if no fields .filter((relationshipName) => fieldsByChildRelName[relationshipName].length > 0) .map((relationshipName) => { + const where = subqueryFilters[relationshipName] + ? convertFiltersToWhereClause(subqueryFilters[relationshipName]) + : undefined; + const orderBy = subqueryOrderBys[relationshipName] ? orderByClausesToSoql(subqueryOrderBys[relationshipName]) : undefined; + const limitRaw = subqueryLimits[relationshipName]; + const limit = limitRaw && limitRaw.trim() ? Number(limitRaw) : undefined; + const subquery: Subquery = { fields: convertFieldWithPolymorphicToQueryFields(fieldsByChildRelName[relationshipName]), relationshipName, + ...(where ? { where } : {}), + ...(orderBy && orderBy.length ? { orderBy } : {}), + ...(typeof limit === 'number' && !Number.isNaN(limit) ? { limit } : {}), }; return getField({ subquery }); }), @@ -59,6 +93,37 @@ export const selectQueryField = atom((get) => { return fields; }); +export type SubqueryOptionsSummary = { + filterCount: number; + hasOrderBy: boolean; + limit: string | null; +}; + +/** + * Per-relationship summary of configured subquery options. + * Drives the collapsed-accordion badge and any "is configured" checks. + */ +export const subqueryOptionsSummaryState = atom>((get) => { + const filters = get(querySubqueryFiltersState); + const orderBys = get(querySubqueryOrderByState); + const limits = get(querySubqueryLimitState); + const keys = new Set([...Object.keys(filters), ...Object.keys(orderBys), ...Object.keys(limits)]); + const result: Record = {}; + keys.forEach((key) => { + const filter = filters[key]; + const filterCount = + filter?.rows?.flatMap((row) => (isExpressionConditionType(row) ? row : row.rows)).filter((row) => queryFilterHasValue(row)).length || + 0; + const hasOrderBy = (orderBys[key] || []).some((orderBy) => !!orderBy.field); + const limitValue = limits[key]?.trim(); + const limit = limitValue ? limitValue : null; + if (filterCount || hasOrderBy || limit) { + result[key] = { filterCount, hasOrderBy, limit }; + } + }); + return result; +}); + export const filterQueryFieldsState = atomWithReset([]); export const groupByQueryFieldsState = atomWithReset([]); export const orderByQueryFieldsState = atomWithReset([]); diff --git a/libs/ui/src/lib/layout/Panel.tsx b/libs/ui/src/lib/layout/Panel.tsx index 77296076c..b4ce03238 100644 --- a/libs/ui/src/lib/layout/Panel.tsx +++ b/libs/ui/src/lib/layout/Panel.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; import { PositionLeftRight, SizeSmMdLgXlFull } from '@jetstream/types'; import classNames from 'classnames'; -import { FunctionComponent, useState } from 'react'; +import { FunctionComponent, useEffect, useState } from 'react'; import Icon from '../widgets/Icon'; export interface PanelProps { @@ -12,6 +12,19 @@ export interface PanelProps { position?: PositionLeftRight; size?: SizeSmMdLgXlFull; showBackArrow?: boolean; + /** + * Close the panel when the user presses Escape. Default: false (opt-in). + * The listener is attached at the document level while isOpen, so nested inputs + * (Monaco, textareas, selection flows) need to stop propagation if they want + * to handle Escape locally without closing the panel. + */ + closeOnEscape?: boolean; + /** + * Override the stacking order. Defaults: 8000 when fullHeight (above app chrome + * and popovers/comboboxes at 7000), 2 otherwise (preserves legacy behavior). + * Note: fullHeight panels use `position: fixed` and anchor to the viewport. + */ + zIndex?: number; onClosed: () => void; children?: React.ReactNode; } @@ -52,24 +65,40 @@ export const Panel: FunctionComponent = ({ position = 'left', size: userSize = 'md', showBackArrow, + closeOnEscape = false, + zIndex, onClosed, children, }) => { const [expanded, setExpanded] = useState(false); + useEffect(() => { + if (!isOpen || !closeOnEscape) { + return; + } + const handler = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClosed(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, closeOnEscape, onClosed]); + if (!isOpen) { return null; } const size: SizeSmMdLgXlFull = expanded ? 'full' : userSize; const expandCollapseIcon = expanded ? 'contract_alt' : 'expand_alt'; + const resolvedZIndex = zIndex ?? (fullHeight ? 8000 : 2); return (