compositeSelect.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import {useMemo, useState} from 'react';
  2. import CompactSelect from 'sentry/components/compactSelect';
  3. import {GeneralSelectValue} from 'sentry/components/forms/controls/selectControl';
  4. import {valueIsEqual} from 'sentry/utils';
  5. /**
  6. * CompositeSelect simulates independent selectors inside the same dropdown
  7. * menu. Each selector is called a "section". The selection value of one
  8. * section does not affect the value of the others.
  9. */
  10. type Section<OptionType> = {
  11. /**
  12. * Text label that will be display on top of the section.
  13. */
  14. label: string;
  15. /**
  16. * Selectable options inside the section.
  17. */
  18. options: OptionType[];
  19. /**
  20. * Must be a unique identifying key for the section. This value will be
  21. * used in the onChange return value. For example, if there are two
  22. * sections, "section1" and "section2", then the onChange callback will be
  23. * invoked as onChange({section1: [selected option values], section2:
  24. * [selected option values]}).
  25. */
  26. value: string;
  27. defaultValue?: any;
  28. /**
  29. * Whether the section has multiple (versus) single selection.
  30. */
  31. multiple?: boolean;
  32. onChange?: (value: any) => void;
  33. };
  34. type ExtendedOptionType = GeneralSelectValue & {
  35. selectionMode?: 'multiple' | 'single';
  36. };
  37. type Props<OptionType> = Omit<
  38. React.ComponentProps<typeof CompactSelect>,
  39. 'multiple' | 'defaultValue' | 'onChange'
  40. > & {
  41. /**
  42. * Array containing the independent selection sections. NOTE: This array
  43. * should not change (i.e. we shouldn't add/remove sections) during the
  44. * component's lifecycle. Updating the options array inside sech section is
  45. * fine.
  46. */
  47. sections: Section<OptionType>[];
  48. };
  49. /**
  50. * Special version of CompactSelect that simulates independent selectors (here
  51. * implemented as "sections") within the same dropdown menu.
  52. */
  53. function CompositeSelect<OptionType extends GeneralSelectValue = GeneralSelectValue>({
  54. sections,
  55. ...props
  56. }: Props<OptionType>) {
  57. const [values, setValues] = useState(sections.map(section => section.defaultValue));
  58. /**
  59. * Object that maps an option value (e.g. "opt_one") to its parent section's index,
  60. * to be used in onChangeValueMap.
  61. */
  62. const optionsMap = useMemo(() => {
  63. const allOptions = sections
  64. .map((section, i) => section.options.map(opt => [opt.value, i]))
  65. .flat();
  66. return Object.fromEntries(allOptions);
  67. }, [sections]);
  68. /**
  69. * Options with the "selectionMode" key attached. This key overrides the
  70. * isMulti setting from SelectControl and forces SelectOption
  71. * (./selectOption.tsx) to display either a chekmark or a checkbox based on
  72. * the selection mode of its parent section, rather than the selection mode
  73. * of the entire select menu.
  74. */
  75. const options = useMemo(() => {
  76. return sections.map(section => ({
  77. ...section,
  78. options: section.options.map(
  79. opt =>
  80. ({
  81. ...opt,
  82. selectionMode: section.multiple ? 'multiple' : 'single',
  83. } as ExtendedOptionType)
  84. ),
  85. }));
  86. }, [sections]);
  87. /**
  88. * Intercepts the incoming set of selected values, and trims it so that
  89. * single-selection sections will only have one selected value at a time.
  90. */
  91. function onChangeValueMap(selectedOptions: ExtendedOptionType[]) {
  92. const newValues = new Array(sections.length).fill(undefined);
  93. selectedOptions.forEach(option => {
  94. const parentSectionIndex = optionsMap[option.value];
  95. const parentSection = sections[parentSectionIndex];
  96. // If the section allows multiple selection, then add the value to the
  97. // list of selected values
  98. if (parentSection.multiple) {
  99. if (!newValues[parentSectionIndex]) {
  100. newValues[parentSectionIndex] = [];
  101. }
  102. newValues[parentSectionIndex].push(option.value);
  103. return;
  104. }
  105. // If the section allows only single selection, then replace whatever the
  106. // old value is with the new one.
  107. if (option.value) {
  108. newValues[parentSectionIndex] = option.value;
  109. }
  110. });
  111. sections.forEach((section, i) => {
  112. // Prevent sections with single selection from losing their values. This might
  113. // happen if the user clicks on an already-selected option.
  114. if (!section.multiple && !newValues[i]) {
  115. newValues[i] = values[i];
  116. // Return an empty array for sections with multiple selection without any value.
  117. } else if (!newValues[i]) {
  118. newValues[i] = [];
  119. }
  120. // Trigger the onChange callback for sections whose values have changed.
  121. if (!valueIsEqual(values[i], newValues[i])) {
  122. sections[i].onChange?.(newValues[i]);
  123. }
  124. });
  125. setValues(newValues);
  126. // Return a flattened array of the selected values to be used inside
  127. // CompactSelect and SelectControl.
  128. return newValues.flat();
  129. }
  130. return (
  131. <CompactSelect
  132. {...props}
  133. multiple
  134. options={options}
  135. defaultValue={sections.map(section => section.defaultValue).flat()}
  136. onChangeValueMap={onChangeValueMap}
  137. />
  138. );
  139. }
  140. export default CompositeSelect;