import {useEffect, useState} from 'react'; import styled from '@emotion/styled'; import {Button} from 'sentry/components/button'; import type {FormFieldProps} from 'sentry/components/forms/formField'; import FormField from 'sentry/components/forms/formField'; import FormFieldControlState from 'sentry/components/forms/formField/controlState'; import Input from 'sentry/components/input'; import {IconAdd, IconDelete} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {uniqueId} from 'sentry/utils/guid'; /** * Matches characters that are not valid in a header name. */ const INVALID_NAME_HEADER_REGEX = new RegExp(/[^a-zA-Z0-9_-]+/g); type HeaderEntry = [id: string, name: string, value: string]; // XXX(epurkhiser): The types of the FormField render props are absolutely // abysmal, so we're leaving this untyped for now. function UptimHeadersControl(props) { const {onChange, onBlur, disabled, model, name, value} = props; // Store itmes in local state so we can add empty values without persisting // those into the form model. const [items, setItems] = useState( Object.keys(value).length > 0 ? value.map(v => [uniqueId(), ...v] as HeaderEntry) : [[uniqueId(), '', '']] ); // Persist the field value back to the form model on changes to the items // list. Empty items are discarded and not persisted. useEffect(() => { const newValue = items.filter(item => item[1] !== '').map(item => [item[1], item[2]]); onChange(newValue, {}); onBlur(newValue, {}); }, [items, onChange, onBlur]); function addItem() { setItems(currentItems => [...currentItems, [uniqueId(), '', '']]); } function removeItem(index: number) { setItems(currentItems => currentItems.toSpliced(index, 1)); } function handleNameChange(index: number, newName: string) { setItems(currentItems => currentItems.toSpliced(index, 1, [ items[index][0], newName.replaceAll(INVALID_NAME_HEADER_REGEX, ''), items[index][2], ]) ); } function handleValueChange(index: number, newHeaderValue: string) { setItems(currentItems => currentItems.toSpliced(index, 1, [items[index][0], items[index][1], newHeaderValue]) ); } /** * Disambiguates headers that are named the same by adding a `(x)` number to * the end of the name in the order they were added. */ function disambiguateHeaderName(index: number) { const headerName = items[index][1]; const matchingIndexes = items .map((item, idx) => [idx, item[1]]) .filter(([_, itemName]) => itemName === headerName) .map(([idx]) => idx); const duplicateIndex = matchingIndexes.indexOf(index) + 1; return duplicateIndex === 1 ? headerName : `${headerName} (${duplicateIndex})`; } return ( {items.length > 0 && ( {items.map(([id, headerName, headerValue], index) => ( handleNameChange(index, e.target.value)} aria-label={t('Name of header %s', index + 1)} /> handleValueChange(index, e.target.value)} aria-label={ headerName ? t('Value of %s', disambiguateHeaderName(index)) : t('Value of header %s', index + 1) } /> ); } export function UptimeHeadersField(props: Omit) { return ( {({ref: _ref, ...fieldProps}) => } ); } const HeadersContainer = styled('div')` display: flex; flex-direction: column; gap: ${space(1)}; `; const HeaderActions = styled('div')` display: flex; gap: ${space(1.5)}; `; const HeaderItems = styled('fieldset')` display: grid; grid-template-columns: minmax(200px, 1fr) 2fr max-content; gap: ${space(1)}; width: 100%; `; const HeaderRow = styled('div')` display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; align-items: center; `;