uptimeHeadersField.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import {useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import type {FormFieldProps} from 'sentry/components/forms/formField';
  5. import FormField from 'sentry/components/forms/formField';
  6. import FormFieldControlState from 'sentry/components/forms/formField/controlState';
  7. import Input from 'sentry/components/input';
  8. import {IconAdd, IconDelete} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {uniqueId} from 'sentry/utils/guid';
  12. /**
  13. * Matches characters that are not valid in a header name.
  14. */
  15. const INVALID_NAME_HEADER_REGEX = new RegExp(/[^a-zA-Z0-9_-]+/g);
  16. type HeaderEntry = [id: string, name: string, value: string];
  17. // XXX(epurkhiser): The types of the FormField render props are absolutely
  18. // abysmal, so we're leaving this untyped for now.
  19. function UptimHeadersControl(props) {
  20. const {onChange, onBlur, disabled, model, name, value} = props;
  21. // Store itmes in local state so we can add empty values without persisting
  22. // those into the form model.
  23. const [items, setItems] = useState<HeaderEntry[]>(
  24. Object.keys(value).length > 0
  25. ? value.map(v => [uniqueId(), ...v] as HeaderEntry)
  26. : [[uniqueId(), '', '']]
  27. );
  28. // Persist the field value back to the form model on changes to the items
  29. // list. Empty items are discarded and not persisted.
  30. useEffect(() => {
  31. const newValue = items.filter(item => item[1] !== '').map(item => [item[1], item[2]]);
  32. onChange(newValue, {});
  33. onBlur(newValue, {});
  34. }, [items, onChange, onBlur]);
  35. function addItem() {
  36. setItems(currentItems => [...currentItems, [uniqueId(), '', '']]);
  37. }
  38. function removeItem(index: number) {
  39. setItems(currentItems => currentItems.toSpliced(index, 1));
  40. }
  41. function handleNameChange(index: number, newName: string) {
  42. setItems(currentItems =>
  43. currentItems.toSpliced(index, 1, [
  44. items[index]![0],
  45. newName.replaceAll(INVALID_NAME_HEADER_REGEX, ''),
  46. items[index]![2],
  47. ])
  48. );
  49. }
  50. function handleValueChange(index: number, newHeaderValue: string) {
  51. setItems(currentItems =>
  52. currentItems.toSpliced(index, 1, [
  53. items[index]![0],
  54. items[index]![1],
  55. newHeaderValue,
  56. ])
  57. );
  58. }
  59. /**
  60. * Disambiguates headers that are named the same by adding a `(x)` number to
  61. * the end of the name in the order they were added.
  62. */
  63. function disambiguateHeaderName(index: number) {
  64. const headerName = items[index]![1];
  65. const matchingIndexes = items
  66. .map((item, idx) => [idx, item[1]])
  67. .filter(([_, itemName]) => itemName === headerName)
  68. .map(([idx]) => idx);
  69. const duplicateIndex = matchingIndexes.indexOf(index) + 1;
  70. return duplicateIndex === 1 ? headerName : `${headerName} (${duplicateIndex})`;
  71. }
  72. return (
  73. <HeadersContainer>
  74. {items.length > 0 && (
  75. <HeaderItems>
  76. {items.map(([id, headerName, headerValue], index) => (
  77. <HeaderRow key={id}>
  78. <Input
  79. monospace
  80. disabled={disabled}
  81. value={headerName ?? ''}
  82. placeholder="X-Header-Value"
  83. onChange={e => handleNameChange(index, e.target.value)}
  84. aria-label={t('Name of header %s', index + 1)}
  85. />
  86. <Input
  87. monospace
  88. disabled={disabled}
  89. value={headerValue ?? ''}
  90. placeholder={t('Header Value')}
  91. onChange={e => handleValueChange(index, e.target.value)}
  92. aria-label={
  93. headerName
  94. ? t('Value of %s', disambiguateHeaderName(index))
  95. : t('Value of header %s', index + 1)
  96. }
  97. />
  98. <Button
  99. disabled={disabled}
  100. icon={<IconDelete />}
  101. size="sm"
  102. borderless
  103. aria-label={
  104. headerName
  105. ? t('Remove %s', disambiguateHeaderName(index))
  106. : t('Remove header %s', index + 1)
  107. }
  108. onClick={() => removeItem(index)}
  109. />
  110. </HeaderRow>
  111. ))}
  112. </HeaderItems>
  113. )}
  114. <HeaderActions>
  115. <Button disabled={disabled} icon={<IconAdd />} size="sm" onClick={addItem}>
  116. {t('Add Header')}
  117. </Button>
  118. <FormFieldControlState model={model} name={name} />
  119. </HeaderActions>
  120. </HeadersContainer>
  121. );
  122. }
  123. export function UptimeHeadersField(props: Omit<FormFieldProps, 'children'>) {
  124. return (
  125. <FormField defaultValue={[]} {...props} hideControlState flexibleControlStateSize>
  126. {({ref: _ref, ...fieldProps}) => <UptimHeadersControl {...fieldProps} />}
  127. </FormField>
  128. );
  129. }
  130. const HeadersContainer = styled('div')`
  131. display: flex;
  132. flex-direction: column;
  133. gap: ${space(1)};
  134. `;
  135. const HeaderActions = styled('div')`
  136. display: flex;
  137. gap: ${space(1.5)};
  138. `;
  139. const HeaderItems = styled('fieldset')`
  140. display: grid;
  141. grid-template-columns: minmax(200px, 1fr) 2fr max-content;
  142. gap: ${space(1)};
  143. width: 100%;
  144. `;
  145. const HeaderRow = styled('div')`
  146. display: grid;
  147. grid-template-columns: subgrid;
  148. grid-column: 1 / -1;
  149. align-items: center;
  150. `;