columnEditorModal.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import {Fragment, useMemo, useState} from 'react';
  2. import {
  3. closestCenter,
  4. DndContext,
  5. KeyboardSensor,
  6. PointerSensor,
  7. useSensor,
  8. useSensors,
  9. } from '@dnd-kit/core';
  10. import {
  11. arrayMove,
  12. SortableContext,
  13. sortableKeyboardCoordinates,
  14. useSortable,
  15. verticalListSortingStrategy,
  16. } from '@dnd-kit/sortable';
  17. import {CSS} from '@dnd-kit/utilities';
  18. import styled from '@emotion/styled';
  19. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  20. import {Button, LinkButton} from 'sentry/components/button';
  21. import ButtonBar from 'sentry/components/buttonBar';
  22. import type {SelectKey, SelectOption} from 'sentry/components/compactSelect';
  23. import {CompactSelect} from 'sentry/components/compactSelect';
  24. import {SPAN_PROPS_DOCS_URL} from 'sentry/constants';
  25. import {IconAdd} from 'sentry/icons/iconAdd';
  26. import {IconDelete} from 'sentry/icons/iconDelete';
  27. import {IconGrabbable} from 'sentry/icons/iconGrabbable';
  28. import {t} from 'sentry/locale';
  29. import type {TagCollection} from 'sentry/types/group';
  30. import {defined} from 'sentry/utils';
  31. type Column = {
  32. column: string | undefined;
  33. id: number;
  34. };
  35. interface ColumnEditorModalProps extends ModalRenderProps {
  36. columns: string[];
  37. onColumnsChange: (fields: string[]) => void;
  38. tags: TagCollection;
  39. }
  40. export function ColumnEditorModal({
  41. Header,
  42. Body,
  43. Footer,
  44. closeModal,
  45. columns,
  46. onColumnsChange,
  47. tags,
  48. }: ColumnEditorModalProps) {
  49. const [editableColumns, setEditableColumns] = useState<Column[]>(
  50. // falsey ids are not draggable in dndkit
  51. columns.map((column, i) => ({id: i + 1, column}))
  52. );
  53. const [nextId, setNextId] = useState(columns.length + 1);
  54. const tagOptions = useMemo(() => {
  55. return Object.values(tags).map(tag => {
  56. return {
  57. label: tag.name,
  58. value: tag.key,
  59. };
  60. });
  61. }, [tags]);
  62. function handleApply() {
  63. onColumnsChange(editableColumns.map(({column}) => column).filter(defined));
  64. closeModal();
  65. }
  66. function insertColumn() {
  67. setEditableColumns(oldEditableColumns => {
  68. const newEditableColumns = oldEditableColumns.slice();
  69. newEditableColumns.push({id: nextId, column: undefined});
  70. setNextId(nextId + 1); // make sure to increment the id for the next time
  71. return newEditableColumns;
  72. });
  73. }
  74. function updateColumnAtIndex(i: number, column: string) {
  75. setEditableColumns(oldEditableColumns => {
  76. const newEditableColumns = [...oldEditableColumns];
  77. newEditableColumns[i].column = column;
  78. return newEditableColumns;
  79. });
  80. }
  81. function deleteColumnAtIndex(i: number) {
  82. setEditableColumns(oldEditableColumns => {
  83. return [...oldEditableColumns.slice(0, i), ...oldEditableColumns.slice(i + 1)];
  84. });
  85. }
  86. function swapColumnsAtIndex(i: number, j: number) {
  87. setEditableColumns(oldEditableColumns => {
  88. return arrayMove(oldEditableColumns, i, j);
  89. });
  90. }
  91. return (
  92. <Fragment>
  93. <Header closeButton data-test-id="editor-header">
  94. <h4>{t('Edit Columns')}</h4>
  95. </Header>
  96. <Body data-test-id="editor-body">
  97. <ColumnEditor
  98. columns={editableColumns}
  99. onColumnChange={updateColumnAtIndex}
  100. onColumnDelete={deleteColumnAtIndex}
  101. onColumnSwap={swapColumnsAtIndex}
  102. tags={tagOptions}
  103. />
  104. <RowContainer>
  105. <ButtonBar gap={1}>
  106. <Button
  107. size="sm"
  108. aria-label={t('Add a Column')}
  109. onClick={insertColumn}
  110. icon={<IconAdd isCircled />}
  111. >
  112. {t('Add a Column')}
  113. </Button>
  114. </ButtonBar>
  115. </RowContainer>
  116. </Body>
  117. <Footer data-test-id="editor-footer">
  118. <ButtonBar gap={1}>
  119. <LinkButton priority="default" href={SPAN_PROPS_DOCS_URL} external>
  120. {t('Read the Docs')}
  121. </LinkButton>
  122. <Button aria-label={t('Apply')} priority="primary" onClick={handleApply}>
  123. {t('Apply')}
  124. </Button>
  125. </ButtonBar>
  126. </Footer>
  127. </Fragment>
  128. );
  129. }
  130. interface ColumnEditorProps {
  131. columns: Column[];
  132. onColumnChange: (i: number, column: string) => void;
  133. onColumnDelete: (i: number) => void;
  134. onColumnSwap: (i: number, j: number) => void;
  135. tags: SelectOption<string>[];
  136. }
  137. function ColumnEditor({
  138. columns,
  139. onColumnChange,
  140. onColumnDelete,
  141. onColumnSwap,
  142. tags,
  143. }: ColumnEditorProps) {
  144. const sensors = useSensors(
  145. useSensor(PointerSensor),
  146. useSensor(KeyboardSensor, {
  147. coordinateGetter: sortableKeyboardCoordinates,
  148. })
  149. );
  150. function handleDragEnd(event) {
  151. const {active, over} = event;
  152. if (active.id !== over.id) {
  153. const oldIndex = columns.findIndex(({id}) => id === active.id);
  154. const newIndex = columns.findIndex(({id}) => id === over.id);
  155. onColumnSwap(oldIndex, newIndex);
  156. }
  157. }
  158. return (
  159. <DndContext
  160. sensors={sensors}
  161. collisionDetection={closestCenter}
  162. onDragEnd={handleDragEnd}
  163. >
  164. <SortableContext items={columns} strategy={verticalListSortingStrategy}>
  165. {columns.map((column, i) => {
  166. return (
  167. <ColumnEditorRow
  168. key={column.id}
  169. canDelete={columns.length > 1}
  170. column={column}
  171. tags={tags}
  172. onColumnChange={c => onColumnChange(i, c)}
  173. onColumnDelete={() => onColumnDelete(i)}
  174. />
  175. );
  176. })}
  177. </SortableContext>
  178. </DndContext>
  179. );
  180. }
  181. interface ColumnEditorRowProps {
  182. canDelete: boolean;
  183. column: Column;
  184. onColumnChange: (column: string) => void;
  185. onColumnDelete: () => void;
  186. tags: SelectOption<string>[];
  187. }
  188. function ColumnEditorRow({
  189. canDelete,
  190. column,
  191. tags,
  192. onColumnChange,
  193. onColumnDelete,
  194. }: ColumnEditorRowProps) {
  195. const {attributes, listeners, setNodeRef, transform, transition} = useSortable({
  196. id: column.id,
  197. });
  198. function handleColumnChange(option: SelectOption<SelectKey>) {
  199. if (defined(option) && typeof option.value === 'string') {
  200. onColumnChange(option.value);
  201. }
  202. }
  203. return (
  204. <RowContainer
  205. key={column.id}
  206. ref={setNodeRef}
  207. style={{
  208. transform: CSS.Transform.toString(transform),
  209. transition,
  210. }}
  211. {...attributes}
  212. >
  213. <Button
  214. aria-label={t('Drag to reorder')}
  215. borderless
  216. size="sm"
  217. icon={<IconGrabbable size="sm" />}
  218. {...listeners}
  219. />
  220. <StyledCompactSelect
  221. data-test-id="editor-column"
  222. options={tags}
  223. value={column.column ?? ''}
  224. onChange={handleColumnChange}
  225. searchable
  226. />
  227. <Button
  228. aria-label={t('Remove Column')}
  229. borderless
  230. disabled={!canDelete}
  231. size="sm"
  232. icon={<IconDelete size="sm" />}
  233. onClick={() => onColumnDelete()}
  234. />
  235. </RowContainer>
  236. );
  237. }
  238. const RowContainer = styled('div')`
  239. display: flex;
  240. flex-direction: row;
  241. `;
  242. const StyledCompactSelect = styled(CompactSelect)`
  243. flex-grow: 1;
  244. `;