columnEditorModal.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import {Fragment, useMemo, useState} from 'react';
  2. import {useSortable} from '@dnd-kit/sortable';
  3. import {CSS} from '@dnd-kit/utilities';
  4. import styled from '@emotion/styled';
  5. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  6. import {Button, LinkButton} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import type {SelectKey, SelectOption} from 'sentry/components/compactSelect';
  9. import {CompactSelect} from 'sentry/components/compactSelect';
  10. import {SPAN_PROPS_DOCS_URL} from 'sentry/constants';
  11. import {IconAdd} from 'sentry/icons/iconAdd';
  12. import {IconDelete} from 'sentry/icons/iconDelete';
  13. import {IconGrabbable} from 'sentry/icons/iconGrabbable';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {TagCollection} from 'sentry/types/group';
  17. import {defined} from 'sentry/utils';
  18. import {classifyTagKey, prettifyTagKey} from 'sentry/utils/discover/fields';
  19. import {FieldKind} from 'sentry/utils/fields';
  20. import {TypeBadge} from 'sentry/views/explore/components/typeBadge';
  21. import {DragNDropContext} from '../contexts/dragNDropContext';
  22. import type {Column} from '../hooks/useDragNDropColumns';
  23. interface ColumnEditorModalProps extends ModalRenderProps {
  24. columns: string[];
  25. numberTags: TagCollection;
  26. onColumnsChange: (fields: string[]) => void;
  27. stringTags: TagCollection;
  28. }
  29. export function ColumnEditorModal({
  30. Header,
  31. Body,
  32. Footer,
  33. closeModal,
  34. columns,
  35. onColumnsChange,
  36. numberTags,
  37. stringTags,
  38. }: ColumnEditorModalProps) {
  39. const tags: SelectOption<string>[] = useMemo(() => {
  40. const allTags = [
  41. ...columns
  42. .filter(
  43. column =>
  44. !stringTags.hasOwnProperty(column) && !numberTags.hasOwnProperty(column)
  45. )
  46. .map(column => {
  47. return {
  48. label: prettifyTagKey(column),
  49. value: column,
  50. textValue: column,
  51. trailingItems: <TypeBadge kind={classifyTagKey(column)} />,
  52. };
  53. }),
  54. ...Object.values(stringTags).map(tag => {
  55. return {
  56. label: tag.name,
  57. value: tag.key,
  58. textValue: tag.name,
  59. trailingItems: <TypeBadge kind={FieldKind.TAG} />,
  60. };
  61. }),
  62. ...Object.values(numberTags).map(tag => {
  63. return {
  64. label: tag.name,
  65. value: tag.key,
  66. textValue: tag.name,
  67. trailingItems: <TypeBadge kind={FieldKind.MEASUREMENT} />,
  68. };
  69. }),
  70. ];
  71. allTags.sort((a, b) => {
  72. if (a.label < b.label) {
  73. return -1;
  74. }
  75. if (a.label > b.label) {
  76. return 1;
  77. }
  78. return 0;
  79. });
  80. return allTags;
  81. }, [columns, stringTags, numberTags]);
  82. // We keep a temporary state for the columns so that we can apply the changes
  83. // only when the user clicks on the apply button.
  84. const [tempColumns, setTempColumns] = useState<string[]>(columns);
  85. function handleApply() {
  86. onColumnsChange(tempColumns.filter(Boolean));
  87. closeModal();
  88. }
  89. return (
  90. <DragNDropContext columns={tempColumns} setColumns={setTempColumns}>
  91. {({insertColumn, updateColumnAtIndex, deleteColumnAtIndex, editableColumns}) => (
  92. <Fragment>
  93. <Header closeButton data-test-id="editor-header">
  94. <h4>{t('Edit Table')}</h4>
  95. </Header>
  96. <Body data-test-id="editor-body">
  97. {editableColumns.map((column, i) => {
  98. return (
  99. <ColumnEditorRow
  100. key={column.id}
  101. canDelete={editableColumns.length > 1}
  102. column={column}
  103. options={tags}
  104. onColumnChange={c => updateColumnAtIndex(i, c)}
  105. onColumnDelete={() => deleteColumnAtIndex(i)}
  106. />
  107. );
  108. })}
  109. <RowContainer>
  110. <ButtonBar gap={1}>
  111. <Button
  112. size="sm"
  113. aria-label={t('Add a Column')}
  114. onClick={insertColumn}
  115. icon={<IconAdd isCircled />}
  116. >
  117. {t('Add a Column')}
  118. </Button>
  119. </ButtonBar>
  120. </RowContainer>
  121. </Body>
  122. <Footer data-test-id="editor-footer">
  123. <ButtonBar gap={1}>
  124. <LinkButton priority="default" href={SPAN_PROPS_DOCS_URL} external>
  125. {t('Read the Docs')}
  126. </LinkButton>
  127. <Button aria-label={t('Apply')} priority="primary" onClick={handleApply}>
  128. {t('Apply')}
  129. </Button>
  130. </ButtonBar>
  131. </Footer>
  132. </Fragment>
  133. )}
  134. </DragNDropContext>
  135. );
  136. }
  137. interface ColumnEditorRowProps {
  138. canDelete: boolean;
  139. column: Column;
  140. onColumnChange: (column: string) => void;
  141. onColumnDelete: () => void;
  142. options: SelectOption<string>[];
  143. }
  144. function ColumnEditorRow({
  145. canDelete,
  146. column,
  147. options,
  148. onColumnChange,
  149. onColumnDelete,
  150. }: ColumnEditorRowProps) {
  151. const {attributes, listeners, setNodeRef, transform, transition} = useSortable({
  152. id: column.id,
  153. });
  154. function handleColumnChange(option: SelectOption<SelectKey>) {
  155. if (defined(option) && typeof option.value === 'string') {
  156. onColumnChange(option.value);
  157. }
  158. }
  159. // The compact select component uses the option label to render the current
  160. // selection. This overrides it to render in a trailing item showing the type.
  161. const label = useMemo(() => {
  162. if (defined(column.column)) {
  163. const tag = options.find(option => option.value === column.column);
  164. if (defined(tag)) {
  165. return (
  166. <TriggerLabel>
  167. <TriggerLabelText>{tag.label}</TriggerLabelText>
  168. {tag.trailingItems &&
  169. (typeof tag.trailingItems === 'function'
  170. ? tag.trailingItems({
  171. disabled: false,
  172. isFocused: false,
  173. isSelected: false,
  174. })
  175. : tag.trailingItems)}
  176. </TriggerLabel>
  177. );
  178. }
  179. }
  180. return <TriggerLabel>{!column.column && t('None')}</TriggerLabel>;
  181. }, [column.column, options]);
  182. return (
  183. <RowContainer
  184. key={column.id}
  185. ref={setNodeRef}
  186. style={{
  187. transform: CSS.Transform.toString(transform),
  188. transition,
  189. }}
  190. {...attributes}
  191. >
  192. <Button
  193. aria-label={t('Drag to reorder')}
  194. borderless
  195. size="sm"
  196. icon={<IconGrabbable size="sm" />}
  197. {...listeners}
  198. />
  199. <StyledCompactSelect
  200. data-test-id="editor-column"
  201. options={options}
  202. triggerLabel={label}
  203. value={column.column ?? ''}
  204. onChange={handleColumnChange}
  205. searchable
  206. triggerProps={{
  207. prefix: t('Column'),
  208. style: {
  209. width: '100%',
  210. },
  211. }}
  212. />
  213. <Button
  214. aria-label={t('Remove Column')}
  215. borderless
  216. disabled={!canDelete}
  217. size="sm"
  218. icon={<IconDelete size="sm" />}
  219. onClick={() => onColumnDelete()}
  220. />
  221. </RowContainer>
  222. );
  223. }
  224. const RowContainer = styled('div')`
  225. display: flex;
  226. flex-direction: row;
  227. align-items: center;
  228. :not(:first-child) {
  229. margin-top: ${space(1)};
  230. }
  231. `;
  232. const StyledCompactSelect = styled(CompactSelect)`
  233. flex: 1 1;
  234. min-width: 0;
  235. `;
  236. const TriggerLabel = styled('span')`
  237. text-align: left;
  238. line-height: 20px;
  239. display: flex;
  240. justify-content: space-between;
  241. flex: 1 1;
  242. min-width: 0;
  243. `;
  244. const TriggerLabelText = styled('span')`
  245. ${p => p.theme.overflowEllipsis}
  246. `;