uptimeHeadersField.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  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, [items[index][0], items[index][1], newHeaderValue])
  53. );
  54. }
  55. /**
  56. * Disambiguates headers that are named the same by adding a `(x)` number to
  57. * the end of the name in the order they were added.
  58. */
  59. function disambiguateHeaderName(index: number) {
  60. const headerName = items[index][1];
  61. const matchingIndexes = items
  62. .map((item, idx) => [idx, item[1]])
  63. .filter(([_, itemName]) => itemName === headerName)
  64. .map(([idx]) => idx);
  65. const duplicateIndex = matchingIndexes.indexOf(index) + 1;
  66. return duplicateIndex === 1 ? headerName : `${headerName} (${duplicateIndex})`;
  67. }
  68. return (
  69. <HeadersContainer>
  70. {items.length > 0 && (
  71. <HeaderItems>
  72. {items.map(([id, headerName, headerValue], index) => (
  73. <HeaderRow key={id}>
  74. <Input
  75. monospace
  76. disabled={disabled}
  77. value={headerName ?? ''}
  78. placeholder="X-Header-Value"
  79. onChange={e => handleNameChange(index, e.target.value)}
  80. aria-label={t('Name of header %s', index + 1)}
  81. />
  82. <Input
  83. monospace
  84. disabled={disabled}
  85. value={headerValue ?? ''}
  86. placeholder={t('Header Value')}
  87. onChange={e => handleValueChange(index, e.target.value)}
  88. aria-label={
  89. headerName
  90. ? t('Value of %s', disambiguateHeaderName(index))
  91. : t('Value of header %s', index + 1)
  92. }
  93. />
  94. <Button
  95. disabled={disabled}
  96. icon={<IconDelete />}
  97. size="sm"
  98. borderless
  99. aria-label={
  100. headerName
  101. ? t('Remove %s', disambiguateHeaderName(index))
  102. : t('Remove header %s', index + 1)
  103. }
  104. onClick={() => removeItem(index)}
  105. />
  106. </HeaderRow>
  107. ))}
  108. </HeaderItems>
  109. )}
  110. <HeaderActions>
  111. <Button disabled={disabled} icon={<IconAdd />} size="sm" onClick={addItem}>
  112. {t('Add Header')}
  113. </Button>
  114. <FormFieldControlState model={model} name={name} />
  115. </HeaderActions>
  116. </HeadersContainer>
  117. );
  118. }
  119. export function UptimeHeadersField(props: Omit<FormFieldProps, 'children'>) {
  120. return (
  121. <FormField defaultValue={[]} {...props} hideControlState flexibleControlStateSize>
  122. {({ref: _ref, ...fieldProps}) => <UptimHeadersControl {...fieldProps} />}
  123. </FormField>
  124. );
  125. }
  126. const HeadersContainer = styled('div')`
  127. display: flex;
  128. flex-direction: column;
  129. gap: ${space(1)};
  130. `;
  131. const HeaderActions = styled('div')`
  132. display: flex;
  133. gap: ${space(1.5)};
  134. `;
  135. const HeaderItems = styled('fieldset')`
  136. display: grid;
  137. grid-template-columns: minmax(200px, 1fr) 2fr max-content;
  138. gap: ${space(1)};
  139. width: 100%;
  140. `;
  141. const HeaderRow = styled('div')`
  142. display: grid;
  143. grid-template-columns: subgrid;
  144. grid-column: 1 / -1;
  145. align-items: center;
  146. `;