tokenizedQueryGrid.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import {useLayoutEffect, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {AriaGridListOptions} from '@react-aria/gridlist';
  4. import {Item} from '@react-stately/collections';
  5. import type {ListState} from '@react-stately/list';
  6. import {useListState} from '@react-stately/list';
  7. import type {CollectionChildren} from '@react-types/shared';
  8. import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
  9. import {useQueryBuilderGrid} from 'sentry/components/searchQueryBuilder/hooks/useQueryBuilderGrid';
  10. import {useSelectOnDrag} from 'sentry/components/searchQueryBuilder/hooks/useSelectOnDrag';
  11. import {useUndoStack} from 'sentry/components/searchQueryBuilder/hooks/useUndoStack';
  12. import {SelectionKeyHandler} from 'sentry/components/searchQueryBuilder/selectionKeyHandler';
  13. import {SearchQueryBuilderBoolean} from 'sentry/components/searchQueryBuilder/tokens/boolean';
  14. import {SearchQueryBuilderFilter} from 'sentry/components/searchQueryBuilder/tokens/filter/filter';
  15. import {SearchQueryBuilderFreeText} from 'sentry/components/searchQueryBuilder/tokens/freeText';
  16. import {SearchQueryBuilderParen} from 'sentry/components/searchQueryBuilder/tokens/paren';
  17. import {makeTokenKey} from 'sentry/components/searchQueryBuilder/utils';
  18. import {type ParseResultToken, Token} from 'sentry/components/searchSyntax/parser';
  19. import {t} from 'sentry/locale';
  20. import {space} from 'sentry/styles/space';
  21. interface TokenizedQueryGridProps {
  22. label?: string;
  23. }
  24. interface GridProps extends AriaGridListOptions<ParseResultToken> {
  25. children: CollectionChildren<ParseResultToken>;
  26. items: ParseResultToken[];
  27. }
  28. function useApplyFocusOverride(state: ListState<ParseResultToken>) {
  29. const {focusOverride, dispatch} = useSearchQueryBuilder();
  30. useLayoutEffect(() => {
  31. if (focusOverride && !focusOverride.part) {
  32. state.selectionManager.setFocused(true);
  33. state.selectionManager.setFocusedKey(focusOverride.itemKey);
  34. dispatch({type: 'RESET_FOCUS_OVERRIDE'});
  35. }
  36. }, [dispatch, focusOverride, state.selectionManager]);
  37. }
  38. function Grid(props: GridProps) {
  39. const ref = useRef<HTMLDivElement>(null);
  40. const selectionKeyHandlerRef = useRef<HTMLInputElement>(null);
  41. const {size} = useSearchQueryBuilder();
  42. const state = useListState<ParseResultToken>({
  43. ...props,
  44. selectionBehavior: 'replace',
  45. onSelectionChange: selection => {
  46. // When there is a selection, focus the SelectionKeyHandler which will
  47. // handle keyboard events in this state.
  48. if (selection === 'all' || selection.size > 0) {
  49. state.selectionManager.setFocused(true);
  50. state.selectionManager.setFocusedKey(null);
  51. selectionKeyHandlerRef.current?.focus();
  52. }
  53. },
  54. });
  55. const {undo} = useUndoStack(state);
  56. const {gridProps} = useQueryBuilderGrid({
  57. props,
  58. state,
  59. ref,
  60. selectionKeyHandlerRef,
  61. undo,
  62. });
  63. useApplyFocusOverride(state);
  64. useSelectOnDrag(state);
  65. return (
  66. <SearchQueryGridWrapper {...gridProps} ref={ref} size={size}>
  67. <SelectionKeyHandler ref={selectionKeyHandlerRef} state={state} undo={undo} />
  68. {[...state.collection].map(item => {
  69. const token = item.value;
  70. switch (token?.type) {
  71. case Token.FILTER:
  72. return (
  73. <SearchQueryBuilderFilter
  74. key={item.key}
  75. token={token}
  76. item={item}
  77. state={state}
  78. />
  79. );
  80. case Token.FREE_TEXT:
  81. return (
  82. <SearchQueryBuilderFreeText
  83. key={item.key}
  84. token={token}
  85. item={item}
  86. state={state}
  87. />
  88. );
  89. case Token.L_PAREN:
  90. case Token.R_PAREN:
  91. return (
  92. <SearchQueryBuilderParen
  93. key={item.key}
  94. token={token}
  95. item={item}
  96. state={state}
  97. />
  98. );
  99. case Token.LOGIC_BOOLEAN:
  100. return (
  101. <SearchQueryBuilderBoolean
  102. key={item.key}
  103. token={token}
  104. item={item}
  105. state={state}
  106. />
  107. );
  108. // TODO(malwilley): Add other token types
  109. default:
  110. return null;
  111. }
  112. })}
  113. </SearchQueryGridWrapper>
  114. );
  115. }
  116. export function TokenizedQueryGrid({label}: TokenizedQueryGridProps) {
  117. const {parsedQuery} = useSearchQueryBuilder();
  118. // Shouldn't ever get here since we will render the plain text input instead
  119. if (!parsedQuery) {
  120. return null;
  121. }
  122. return (
  123. <Grid
  124. aria-label={label ?? t('Create a search query')}
  125. items={parsedQuery}
  126. selectionMode="multiple"
  127. >
  128. {item => (
  129. <Item key={makeTokenKey(item, parsedQuery)}>
  130. {item.text.trim() ? item.text : t('Space')}
  131. </Item>
  132. )}
  133. </Grid>
  134. );
  135. }
  136. const SearchQueryGridWrapper = styled('div')<{size: 'small' | 'normal'}>`
  137. padding: ${p =>
  138. p.size === 'small' ? space(0.75) : `${space(0.75)} 34px ${space(0.75)} 32px`};
  139. display: flex;
  140. align-items: stretch;
  141. row-gap: ${space(0.5)};
  142. flex-wrap: wrap;
  143. &:focus {
  144. outline: none;
  145. }
  146. `;