columnEditorModal.tsx 6.9 KB

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